From e9137fccc728d5aaeb07df49d34d2dde4cbdd076 Mon Sep 17 00:00:00 2001
From: Jarek Samic <jarek.samic@cldfire.dev>
Date: Mon, 7 Aug 2023 23:40:01 -0400
Subject: [PATCH] Initial port of react native edit transaction view (#1340)

* Set `role="button"` on downshift autocomplete items
This avoids content observation behavior in WebKit on touch devices that delays the onClick event (and therefore reaction to user input).
* Disable split transaction editing for now
---
 .../src/components/FinancesApp.tsx            |  32 +-
 .../src/components/accounts/MobileAccount.js  |  16 +-
 .../accounts/MobileAccountDetails.js          |   2 +-
 .../autocomplete/AccountAutocomplete.js       |  63 +-
 .../components/autocomplete/Autocomplete.tsx  |  26 +
 .../autocomplete/CategorySelect.tsx           |  67 +-
 .../autocomplete/PayeeAutocomplete.js         |  70 +-
 .../components/mobile/MobileAmountInput.js    | 315 ++++++
 .../src/components/mobile/MobileForms.js      | 127 +++
 .../src/components/modals/EditField.js        |  57 +-
 .../transactions/MobileTransaction.js         | 924 ++++++++++++++++--
 packages/loot-core/src/shared/transactions.ts |   2 +-
 upcoming-release-notes/1340.md                |   6 +
 13 files changed, 1562 insertions(+), 145 deletions(-)
 create mode 100644 packages/desktop-client/src/components/mobile/MobileAmountInput.js
 create mode 100644 packages/desktop-client/src/components/mobile/MobileForms.js
 create mode 100644 upcoming-release-notes/1340.md

diff --git a/packages/desktop-client/src/components/FinancesApp.tsx b/packages/desktop-client/src/components/FinancesApp.tsx
index 57deef990..4f5b0f8d2 100644
--- a/packages/desktop-client/src/components/FinancesApp.tsx
+++ b/packages/desktop-client/src/components/FinancesApp.tsx
@@ -43,6 +43,7 @@ import { NarrowAlternate, WideComponent } from './responsive';
 import PostsOfflineNotification from './schedules/PostsOfflineNotification';
 import Settings from './settings';
 import Titlebar, { TitlebarProvider } from './Titlebar';
+import { TransactionEdit } from './transactions/MobileTransaction';
 
 function NarrowNotSupported({
   redirectTo = '/budget',
@@ -61,6 +62,17 @@ function NarrowNotSupported({
   return isNarrowWidth ? null : children;
 }
 
+function WideNotSupported({ children, redirectTo = '/budget' }) {
+  const { isNarrowWidth } = useResponsive();
+  const navigate = useNavigate();
+  useEffect(() => {
+    if (!isNarrowWidth) {
+      navigate(redirectTo);
+    }
+  }, [isNarrowWidth, navigate, redirectTo]);
+  return isNarrowWidth ? children : null;
+}
+
 function StackedRoutesInner({ location }) {
   return (
     <Routes location={location}>
@@ -147,12 +159,30 @@ function StackedRoutesInner({ location }) {
         }
       />
 
+      <Route path="/accounts" element={<NarrowAlternate name="Accounts" />} />
+
       <Route
         path="/accounts/:id"
         element={<NarrowAlternate name="Account" />}
       />
 
-      <Route path="/accounts" element={<NarrowAlternate name="Accounts" />} />
+      <Route
+        path="/accounts/:id/transactions/:transactionId"
+        element={
+          <WideNotSupported>
+            <TransactionEdit />
+          </WideNotSupported>
+        }
+      />
+
+      <Route
+        path="/accounts/:id/transactions/new"
+        element={
+          <WideNotSupported>
+            <TransactionEdit />
+          </WideNotSupported>
+        }
+      />
     </Routes>
   );
 }
diff --git a/packages/desktop-client/src/components/accounts/MobileAccount.js b/packages/desktop-client/src/components/accounts/MobileAccount.js
index 720596e54..4a11fd30d 100644
--- a/packages/desktop-client/src/components/accounts/MobileAccount.js
+++ b/packages/desktop-client/src/components/accounts/MobileAccount.js
@@ -15,7 +15,6 @@ import * as queries from 'loot-core/src/client/queries';
 import { pagedQuery } from 'loot-core/src/client/query-helpers';
 import { send, listen } from 'loot-core/src/platform/client/fetch';
 import {
-  getSplit,
   isPreviewId,
   ungroupTransactions,
 } from 'loot-core/src/shared/transactions';
@@ -185,7 +184,6 @@ export default function Account(props) {
     setSearchText(text);
   };
 
-  // eslint-disable-next-line @typescript-eslint/no-unused-vars
   const onSelectTransaction = transaction => {
     if (isPreviewId(transaction.id)) {
       let parts = transaction.id.split('/');
@@ -214,17 +212,7 @@ export default function Account(props) {
         },
       );
     } else {
-      let trans = [transaction];
-      if (transaction.parent_id || transaction.is_parent) {
-        let index = transactions.findIndex(
-          t => t.id === (transaction.parent_id || transaction.id),
-        );
-        trans = getSplit(transactions, index);
-      }
-
-      navigate('Transaction', {
-        transactions: trans,
-      });
+      navigate(`transactions/${transaction.id}`);
     }
   };
 
@@ -269,7 +257,7 @@ export default function Account(props) {
                     paged?.fetchNext();
                   }}
                   onSearch={onSearch}
-                  onSelectTransaction={() => {}} // onSelectTransaction}
+                  onSelectTransaction={onSelectTransaction}
                 />
               )
             }
diff --git a/packages/desktop-client/src/components/accounts/MobileAccountDetails.js b/packages/desktop-client/src/components/accounts/MobileAccountDetails.js
index 4c9ecb5b7..88281b6f0 100644
--- a/packages/desktop-client/src/components/accounts/MobileAccountDetails.js
+++ b/packages/desktop-client/src/components/accounts/MobileAccountDetails.js
@@ -133,7 +133,7 @@ export default function AccountDetails({
               TODO: connect to an add transaction modal
               Only left here but hidden for flex centering of the account name.
           */}
-          <Link to="transaction/new" style={{ visibility: 'hidden' }}>
+          <Link to="transactions/new">
             <Button
               type="bare"
               style={{ justifyContent: 'center', width: LEFT_RIGHT_FLEX_WIDTH }}
diff --git a/packages/desktop-client/src/components/autocomplete/AccountAutocomplete.js b/packages/desktop-client/src/components/autocomplete/AccountAutocomplete.js
index a0c693fc3..f1918eda8 100644
--- a/packages/desktop-client/src/components/autocomplete/AccountAutocomplete.js
+++ b/packages/desktop-client/src/components/autocomplete/AccountAutocomplete.js
@@ -1,14 +1,27 @@
 import React from 'react';
 
+import { css } from 'glamor';
+
 import { useCachedAccounts } from 'loot-core/src/client/data-hooks/accounts';
 
+import { useResponsive } from '../../ResponsiveProvider';
 import { colors } from '../../style';
 import View from '../common/View';
 
 import Autocomplete from './Autocomplete';
 
-function AccountList({ items, getItemProps, highlightedIndex, embedded }) {
+function AccountList({
+  items,
+  getItemProps,
+  highlightedIndex,
+  embedded,
+  groupHeaderStyle,
+}) {
   let lastItem = null;
+  const { isNarrowWidth } = useResponsive();
+  const highlightedIndexColor = isNarrowWidth
+    ? 'rgba(100, 100, 100, .15)'
+    : colors.n4;
 
   return (
     <View>
@@ -41,6 +54,7 @@ function AccountList({ items, getItemProps, highlightedIndex, embedded }) {
                 style={{
                   color: colors.y9,
                   padding: '4px 9px',
+                  ...groupHeaderStyle,
                 }}
                 data-testid="account-item-group"
               >
@@ -49,14 +63,43 @@ function AccountList({ items, getItemProps, highlightedIndex, embedded }) {
             ) : null,
             <div
               {...(getItemProps ? getItemProps({ item }) : null)}
+              // Downshift calls `setTimeout(..., 250)` in the `onMouseMove`
+              // event handler they set on this element. When this code runs
+              // in WebKit on touch-enabled devices, taps on this element end
+              // up not triggering the `onClick` event (and therefore delaying
+              // response to user input) until after the `setTimeout` callback
+              // finishes executing. This is caused by content observation code
+              // that implements various strategies to prevent the user from
+              // accidentally clicking content that changed as a result of code
+              // run in the `onMouseMove` event.
+              //
+              // Long story short, we don't want any delay here between the user
+              // tapping and the resulting action being performed. It turns out
+              // there's some "fast path" logic that can be triggered in various
+              // ways to force WebKit to bail on the content observation process.
+              // One of those ways is setting `role="button"` (or a number of
+              // other aria roles) on the element, which is what we're doing here.
+              //
+              // ref:
+              // * https://github.com/WebKit/WebKit/blob/447d90b0c52b2951a69df78f06bb5e6b10262f4b/LayoutTests/fast/events/touch/ios/content-observation/400ms-hover-intent.html
+              // * https://github.com/WebKit/WebKit/blob/58956cf59ba01267644b5e8fe766efa7aa6f0c5c/Source/WebCore/page/ios/ContentChangeObserver.cpp
+              // * https://github.com/WebKit/WebKit/blob/58956cf59ba01267644b5e8fe766efa7aa6f0c5c/Source/WebKit/WebProcess/WebPage/ios/WebPageIOS.mm#L783
+              role="button"
               key={item.id}
-              style={{
-                backgroundColor:
-                  highlightedIndex === idx ? colors.n4 : 'transparent',
-                padding: 4,
-                paddingLeft: 20,
-                borderRadius: embedded ? 4 : 0,
-              }}
+              className={`${css([
+                {
+                  backgroundColor:
+                    highlightedIndex === idx
+                      ? highlightedIndexColor
+                      : 'transparent',
+                  padding: 4,
+                  paddingLeft: 20,
+                  borderRadius: embedded ? 4 : 0,
+                  ':active': {
+                    backgroundColor: 'rgba(100, 100, 100, .25)',
+                  },
+                },
+              ])}`}
               data-testid={
                 'account-item' +
                 (highlightedIndex === idx ? '-highlighted' : '')
@@ -74,6 +117,8 @@ function AccountList({ items, getItemProps, highlightedIndex, embedded }) {
 export default function AccountAutocomplete({
   embedded,
   includeClosedAccounts = true,
+  groupHeaderStyle,
+  closeOnBlur,
   ...props
 }) {
   let accounts = useCachedAccounts() || [];
@@ -97,6 +142,7 @@ export default function AccountAutocomplete({
       strict={true}
       highlightFirst={true}
       embedded={embedded}
+      closeOnBlur={closeOnBlur}
       suggestions={accounts}
       renderItems={(items, getItemProps, highlightedIndex) => (
         <AccountList
@@ -104,6 +150,7 @@ export default function AccountAutocomplete({
           getItemProps={getItemProps}
           highlightedIndex={highlightedIndex}
           embedded={embedded}
+          groupHeaderStyle={groupHeaderStyle}
         />
       )}
       {...props}
diff --git a/packages/desktop-client/src/components/autocomplete/Autocomplete.tsx b/packages/desktop-client/src/components/autocomplete/Autocomplete.tsx
index 0772f71d9..600db987c 100644
--- a/packages/desktop-client/src/components/autocomplete/Autocomplete.tsx
+++ b/packages/desktop-client/src/components/autocomplete/Autocomplete.tsx
@@ -94,6 +94,28 @@ function defaultRenderItems(items, getItemProps, highlightedIndex) {
         return (
           <div
             {...getItemProps({ item })}
+            // Downshift calls `setTimeout(..., 250)` in the `onMouseMove`
+            // event handler they set on this element. When this code runs
+            // in WebKit on touch-enabled devices, taps on this element end
+            // up not triggering the `onClick` event (and therefore delaying
+            // response to user input) until after the `setTimeout` callback
+            // finishes executing. This is caused by content observation code
+            // that implements various strategies to prevent the user from
+            // accidentally clicking content that changed as a result of code
+            // run in the `onMouseMove` event.
+            //
+            // Long story short, we don't want any delay here between the user
+            // tapping and the resulting action being performed. It turns out
+            // there's some "fast path" logic that can be triggered in various
+            // ways to force WebKit to bail on the content observation process.
+            // One of those ways is setting `role="button"` (or a number of
+            // other aria roles) on the element, which is what we're doing here.
+            //
+            // ref:
+            // * https://github.com/WebKit/WebKit/blob/447d90b0c52b2951a69df78f06bb5e6b10262f4b/LayoutTests/fast/events/touch/ios/content-observation/400ms-hover-intent.html
+            // * https://github.com/WebKit/WebKit/blob/58956cf59ba01267644b5e8fe766efa7aa6f0c5c/Source/WebCore/page/ios/ContentChangeObserver.cpp
+            // * https://github.com/WebKit/WebKit/blob/58956cf59ba01267644b5e8fe766efa7aa6f0c5c/Source/WebKit/WebProcess/WebPage/ios/WebPageIOS.mm#L783
+            role="button"
             key={name}
             {...css({
               padding: 5,
@@ -143,6 +165,7 @@ type SingleAutocompleteProps = {
   strict?: boolean;
   onSelect: (id: unknown, value: string) => void;
   tableBehavior?: boolean;
+  closeOnBlur?: boolean;
   value: unknown[];
   isMulti?: boolean;
 };
@@ -167,6 +190,7 @@ function SingleAutocomplete({
   strict,
   onSelect,
   tableBehavior,
+  closeOnBlur = true,
   value: initialValue,
   isMulti = false,
 }: SingleAutocompleteProps) {
@@ -377,6 +401,8 @@ function SingleAutocomplete({
                 e.preventDownshiftDefault = true;
                 inputProps.onBlur?.(e);
 
+                if (!closeOnBlur) return;
+
                 if (!tableBehavior) {
                   if (e.target.value === '') {
                     onSelect?.(null, e.target.value);
diff --git a/packages/desktop-client/src/components/autocomplete/CategorySelect.tsx b/packages/desktop-client/src/components/autocomplete/CategorySelect.tsx
index c10221373..2fddd52ed 100644
--- a/packages/desktop-client/src/components/autocomplete/CategorySelect.tsx
+++ b/packages/desktop-client/src/components/autocomplete/CategorySelect.tsx
@@ -5,7 +5,10 @@ import React, {
   type ReactNode,
 } from 'react';
 
+import { css } from 'glamor';
+
 import Split from '../../icons/v0/Split';
+import { useResponsive } from '../../ResponsiveProvider';
 import { colors } from '../../style';
 import Text from '../common/Text';
 import View from '../common/View';
@@ -31,6 +34,7 @@ export type CategoryListProps = {
   highlightedIndex: number;
   embedded: boolean;
   footer?: ReactNode;
+  groupHeaderStyle?: object;
 };
 function CategoryList({
   items,
@@ -38,8 +42,13 @@ function CategoryList({
   highlightedIndex,
   embedded,
   footer,
+  groupHeaderStyle,
 }: CategoryListProps) {
   let lastGroup = null;
+  const { isNarrowWidth } = useResponsive();
+  const highlightedIndexColor = isNarrowWidth
+    ? 'rgba(100, 100, 100, .15)'
+    : colors.n4;
 
   return (
     <View>
@@ -58,9 +67,33 @@ function CategoryList({
               <View
                 key="split"
                 {...(getItemProps ? getItemProps({ item }) : null)}
+                // Downshift calls `setTimeout(..., 250)` in the `onMouseMove`
+                // event handler they set on this element. When this code runs
+                // in WebKit on touch-enabled devices, taps on this element end
+                // up not triggering the `onClick` event (and therefore delaying
+                // response to user input) until after the `setTimeout` callback
+                // finishes executing. This is caused by content observation code
+                // that implements various strategies to prevent the user from
+                // accidentally clicking content that changed as a result of code
+                // run in the `onMouseMove` event.
+                //
+                // Long story short, we don't want any delay here between the user
+                // tapping and the resulting action being performed. It turns out
+                // there's some "fast path" logic that can be triggered in various
+                // ways to force WebKit to bail on the content observation process.
+                // One of those ways is setting `role="button"` (or a number of
+                // other aria roles) on the element, which is what we're doing here.
+                //
+                // ref:
+                // * https://github.com/WebKit/WebKit/blob/447d90b0c52b2951a69df78f06bb5e6b10262f4b/LayoutTests/fast/events/touch/ios/content-observation/400ms-hover-intent.html
+                // * https://github.com/WebKit/WebKit/blob/58956cf59ba01267644b5e8fe766efa7aa6f0c5c/Source/WebCore/page/ios/ContentChangeObserver.cpp
+                // * https://github.com/WebKit/WebKit/blob/58956cf59ba01267644b5e8fe766efa7aa6f0c5c/Source/WebKit/WebProcess/WebPage/ios/WebPageIOS.mm#L783
+                role="button"
                 style={{
                   backgroundColor:
-                    highlightedIndex === idx ? colors.n4 : 'transparent',
+                    highlightedIndex === idx
+                      ? highlightedIndexColor
+                      : 'transparent',
                   borderRadius: embedded ? 4 : 0,
                   flexShrink: 0,
                   flexDirection: 'row',
@@ -69,6 +102,9 @@ function CategoryList({
                   fontWeight: 500,
                   color: colors.g8,
                   padding: '6px 8px',
+                  ':active': {
+                    backgroundColor: 'rgba(100, 100, 100, .25)',
+                  },
                 }}
                 data-testid="split-transaction-button"
               >
@@ -89,6 +125,7 @@ function CategoryList({
                   style={{
                     color: colors.y9,
                     padding: '4px 9px',
+                    ...groupHeaderStyle,
                   }}
                   data-testid="category-item-group"
                 >
@@ -97,13 +134,22 @@ function CategoryList({
               )}
               <div
                 {...(getItemProps ? getItemProps({ item }) : null)}
-                style={{
-                  backgroundColor:
-                    highlightedIndex === idx ? colors.n4 : 'transparent',
-                  padding: 4,
-                  paddingLeft: 20,
-                  borderRadius: embedded ? 4 : 0,
-                }}
+                // See comment above.
+                role="button"
+                className={`${css([
+                  {
+                    backgroundColor:
+                      highlightedIndex === idx
+                        ? highlightedIndexColor
+                        : 'transparent',
+                    padding: 4,
+                    paddingLeft: 20,
+                    borderRadius: embedded ? 4 : 0,
+                    ':active': {
+                      backgroundColor: 'rgba(100, 100, 100, .25)',
+                    },
+                  },
+                ])}`}
                 data-testid={
                   'category-item' +
                   (highlightedIndex === idx ? '-highlighted' : '')
@@ -123,11 +169,14 @@ function CategoryList({
 type CategoryAutocompleteProps = ComponentProps<typeof Autocomplete> & {
   categoryGroups: CategoryGroup[];
   showSplitOption?: boolean;
+  groupHeaderStyle?: object;
 };
 export default function CategoryAutocomplete({
   categoryGroups,
   showSplitOption,
   embedded,
+  closeOnBlur,
+  groupHeaderStyle,
   ...props
 }: CategoryAutocompleteProps) {
   let categorySuggestions = useMemo(
@@ -150,6 +199,7 @@ export default function CategoryAutocomplete({
       strict={true}
       highlightFirst={true}
       embedded={embedded}
+      closeOnBlur={closeOnBlur}
       getHighlightedIndex={suggestions => {
         if (suggestions.length === 0) {
           return null;
@@ -174,6 +224,7 @@ export default function CategoryAutocomplete({
           embedded={embedded}
           getItemProps={getItemProps}
           highlightedIndex={highlightedIndex}
+          groupHeaderStyle={groupHeaderStyle}
         />
       )}
       {...props}
diff --git a/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.js b/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.js
index 11b73bb41..147be825b 100644
--- a/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.js
+++ b/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.js
@@ -1,12 +1,15 @@
 import React, { Fragment, useState, useMemo } from 'react';
 import { useDispatch } from 'react-redux';
 
+import { css } from 'glamor';
+
 import { createPayee } from 'loot-core/src/client/actions/queries';
 import { useCachedAccounts } from 'loot-core/src/client/data-hooks/accounts';
 import { useCachedPayees } from 'loot-core/src/client/data-hooks/payees';
 import { getActivePayees } from 'loot-core/src/client/reducers/queries';
 
 import Add from '../../icons/v1/Add';
+import { useResponsive } from '../../ResponsiveProvider';
 import { colors } from '../../style';
 import Button from '../common/Button';
 import View from '../common/View';
@@ -48,8 +51,14 @@ function PayeeList({
   highlightedIndex,
   embedded,
   inputValue,
+  groupHeaderStyle,
   footer,
 }) {
+  const { isNarrowWidth } = useResponsive();
+  const highlightedIndexColor = isNarrowWidth
+    ? 'rgba(100, 100, 100, .15)'
+    : colors.n4;
+  const createNewColor = isNarrowWidth ? colors.g5 : colors.g8;
   let isFiltered = items.filtered;
   let createNew = null;
   items = [...items];
@@ -81,16 +90,19 @@ function PayeeList({
               flexShrink: 0,
               padding: '6px 9px',
               backgroundColor:
-                highlightedIndex === 0 ? colors.n4 : 'transparent',
+                highlightedIndex === 0 ? highlightedIndexColor : 'transparent',
               borderRadius: embedded ? 4 : 0,
+              ':active': {
+                backgroundColor: 'rgba(100, 100, 100, .25)',
+              },
             }}
           >
             <View
               style={{
                 display: 'block',
-                color: colors.g8,
+                color: createNewColor,
                 borderRadius: 4,
-                fontSize: 11,
+                fontSize: isNarrowWidth ? 'inherit' : 11,
                 fontWeight: 500,
               }}
             >
@@ -123,6 +135,7 @@ function PayeeList({
                   style={{
                     color: colors.y9,
                     padding: '4px 9px',
+                    ...groupHeaderStyle,
                   }}
                 >
                   {title}
@@ -131,16 +144,43 @@ function PayeeList({
 
               <div
                 {...(getItemProps ? getItemProps({ item }) : null)}
+                // Downshift calls `setTimeout(..., 250)` in the `onMouseMove`
+                // event handler they set on this element. When this code runs
+                // in WebKit on touch-enabled devices, taps on this element end
+                // up not triggering the `onClick` event (and therefore delaying
+                // response to user input) until after the `setTimeout` callback
+                // finishes executing. This is caused by content observation code
+                // that implements various strategies to prevent the user from
+                // accidentally clicking content that changed as a result of code
+                // run in the `onMouseMove` event.
+                //
+                // Long story short, we don't want any delay here between the user
+                // tapping and the resulting action being performed. It turns out
+                // there's some "fast path" logic that can be triggered in various
+                // ways to force WebKit to bail on the content observation process.
+                // One of those ways is setting `role="button"` (or a number of
+                // other aria roles) on the element, which is what we're doing here.
+                //
+                // ref:
+                // * https://github.com/WebKit/WebKit/blob/447d90b0c52b2951a69df78f06bb5e6b10262f4b/LayoutTests/fast/events/touch/ios/content-observation/400ms-hover-intent.html
+                // * https://github.com/WebKit/WebKit/blob/58956cf59ba01267644b5e8fe766efa7aa6f0c5c/Source/WebCore/page/ios/ContentChangeObserver.cpp
+                // * https://github.com/WebKit/WebKit/blob/58956cf59ba01267644b5e8fe766efa7aa6f0c5c/Source/WebKit/WebProcess/WebPage/ios/WebPageIOS.mm#L783
+                role="button"
                 key={item.id}
-                style={{
-                  backgroundColor:
-                    highlightedIndex === idx + offset
-                      ? colors.n4
-                      : 'transparent',
-                  borderRadius: embedded ? 4 : 0,
-                  padding: 4,
-                  paddingLeft: 20,
-                }}
+                className={`${css([
+                  {
+                    backgroundColor:
+                      highlightedIndex === idx + offset
+                        ? highlightedIndexColor
+                        : 'transparent',
+                    borderRadius: embedded ? 4 : 0,
+                    padding: 4,
+                    paddingLeft: 20,
+                    ':active': {
+                      backgroundColor: 'rgba(100, 100, 100, .25)',
+                    },
+                  },
+                ])}`}
               >
                 {item.name}
               </div>
@@ -148,7 +188,7 @@ function PayeeList({
               {showMoreMessage && (
                 <div
                   style={{
-                    fontSize: 11,
+                    fontSize: isNarrowWidth ? 'inherit' : 11,
                     padding: 5,
                     color: colors.n5,
                     textAlign: 'center',
@@ -174,9 +214,11 @@ export default function PayeeAutocomplete({
   defaultFocusTransferPayees = false,
   tableBehavior,
   embedded,
+  closeOnBlur,
   onUpdate,
   onSelect,
   onManagePayees,
+  groupHeaderStyle,
   accounts,
   payees,
   ...props
@@ -238,6 +280,7 @@ export default function PayeeAutocomplete({
       value={stripNew(value)}
       suggestions={payeeSuggestions}
       tableBehavior={tableBehavior}
+      closeOnBlur={closeOnBlur}
       itemToString={item => {
         if (!item) {
           return '';
@@ -324,6 +367,7 @@ export default function PayeeAutocomplete({
           highlightedIndex={highlightedIndex}
           inputValue={inputValue}
           embedded={embedded}
+          groupHeaderStyle={groupHeaderStyle}
           footer={
             <AutocompleteFooter embedded={embedded}>
               {showMakeTransfer && (
diff --git a/packages/desktop-client/src/components/mobile/MobileAmountInput.js b/packages/desktop-client/src/components/mobile/MobileAmountInput.js
new file mode 100644
index 000000000..b49065c9a
--- /dev/null
+++ b/packages/desktop-client/src/components/mobile/MobileAmountInput.js
@@ -0,0 +1,315 @@
+import { PureComponent } from 'react';
+
+import {
+  toRelaxedNumber,
+  amountToCurrency,
+  getNumberFormat,
+} from 'loot-core/src/shared/util';
+
+import { theme } from '../../style';
+import Button from '../common/Button';
+import Text from '../common/Text';
+import View from '../common/View';
+
+function getValue(state) {
+  const { value } = state;
+  return value;
+}
+
+class AmountInput extends PureComponent {
+  static getDerivedStateFromProps(props, state) {
+    return { editing: state.text !== '' || state.editing };
+  }
+
+  constructor(props) {
+    super(props);
+    // this.backgroundValue = new Animated.Value(0);
+    this.backgroundValue = 0;
+
+    this.id = Math.random().toString().slice(0, 5);
+    this.state = {
+      editing: false,
+      text: '',
+      // These are actually set from the props when the field is
+      // focused
+      value: 0,
+    };
+  }
+
+  componentDidMount() {
+    if (this.props.focused) {
+      this.focus();
+    }
+  }
+
+  componentWillUnmount() {
+    if (this.removeListeners) {
+      this.removeListeners();
+    }
+  }
+
+  componentDidUpdate(prevProps, prevState) {
+    if (!prevProps.focused && this.props.focused) {
+      this.focus();
+    }
+
+    if (prevProps.value !== this.props.value) {
+      this.setState({
+        editing: false,
+        text: '',
+        ...this.getInitialValue(),
+      });
+    }
+  }
+
+  parseText() {
+    return toRelaxedNumber(
+      this.state.text.replace(/[,.]/, getNumberFormat().separator),
+    );
+  }
+
+  // animate() {
+  //   this.animation = Animated.sequence([
+  //     Animated.timing(this.backgroundValue, {
+  //       toValue: 1,
+  //       duration: 1200,
+  //       useNativeDriver: true,
+  //     }),
+  //     Animated.timing(this.backgroundValue, {
+  //       toValue: 0,
+  //       duration: 1200,
+  //       useNativeDriver: true,
+  //     }),
+  //   ]);
+
+  //   this.animation.start(({ finished }) => {
+  //     if (finished) {
+  //       this.animate();
+  //     }
+  //   });
+  // }
+
+  onKeyPress = e => {
+    if (e.nativeEvent.key === 'Backspace' && this.state.text === '') {
+      this.setState({ editing: true });
+    }
+  };
+
+  getInitialValue() {
+    return {
+      value: Math.abs(this.props.value),
+    };
+  }
+
+  focus() {
+    this.input.focus();
+
+    const initialState = this.getInitialValue();
+    this.setState(initialState);
+  }
+
+  applyText = () => {
+    const { editing } = this.state;
+
+    const parsed = this.parseText();
+    const newValue = editing ? parsed : getValue(this.state);
+
+    this.setState({
+      value: Math.abs(newValue),
+      editing: false,
+      text: '',
+    });
+
+    return newValue;
+  };
+
+  onBlur = () => {
+    const value = this.applyText();
+    this.props.onBlur?.(value);
+    if (this.removeListeners) {
+      this.removeListeners();
+    }
+  };
+
+  onChangeText = text => {
+    let { onChange } = this.props;
+
+    this.setState({ text });
+    onChange(text);
+  };
+
+  render() {
+    const { style, textStyle } = this.props;
+    const { editing, value, text } = this.state;
+
+    let input = (
+      <input
+        type="text"
+        ref={el => (this.input = el)}
+        value={text}
+        inputMode="decimal"
+        autoCapitalize="none"
+        onChange={e => this.onChangeText(e.target.value)}
+        onBlur={this.onBlur}
+        onKeyPress={this.onKeyPress}
+        data-testid="amount-input"
+        style={{ flex: 1, textAlign: 'center', position: 'absolute' }}
+      />
+    );
+
+    return (
+      <View
+        style={[
+          {
+            justifyContent: 'center',
+            borderWidth: 1,
+            borderColor: theme.pillBorderSelected,
+            borderRadius: 4,
+            padding: 5,
+            backgroundColor: 'white',
+          },
+          style,
+        ]}
+      >
+        <View style={{ overflowY: 'auto' }}>{input}</View>
+
+        {/* <Animated.View
+          style={{
+            position: 'absolute',
+            left: 0,
+            right: 0,
+            bottom: 0,
+            top: 0,
+
+            backgroundColor: animationColor || colors.p10,
+            opacity: this.backgroundValue,
+            borderRadius: 2,
+          }}
+          pointerEvents="none"
+        /> */}
+        <Text
+          style={textStyle}
+          data-testid="amount-fake-input"
+          pointerEvents="none"
+        >
+          {editing ? text : amountToCurrency(value)}
+        </Text>
+      </View>
+    );
+  }
+}
+
+export class FocusableAmountInput extends PureComponent {
+  state = { focused: false, isNegative: true };
+
+  componentDidMount() {
+    if (this.props.sign) {
+      this.setState({ isNegative: this.props.sign === 'negative' });
+    } else if (
+      this.props.value > 0 ||
+      (!this.props.zeroIsNegative && this.props.value === 0)
+    ) {
+      this.setState({ isNegative: false });
+    }
+  }
+
+  focus = () => {
+    this.setState({ focused: true });
+  };
+
+  onFocus = () => {
+    this.focus();
+  };
+
+  toggleIsNegative = () => {
+    this.setState({ isNegative: !this.state.isNegative }, () => {
+      this.onBlur(this.props.value);
+    });
+  };
+
+  onBlur = value => {
+    this.setState({ focused: false, reallyFocused: false });
+    if (this.props.onBlur) {
+      const absValue = Math.abs(value);
+      this.props.onBlur(this.state.isNegative ? -absValue : absValue);
+    }
+  };
+
+  render() {
+    const { textStyle, style, focusedStyle, buttonProps } = this.props;
+    const { focused } = this.state;
+
+    return (
+      <View>
+        <AmountInput
+          {...this.props}
+          ref={el => (this.amount = el)}
+          onBlur={this.onBlur}
+          focused={focused}
+          style={[
+            {
+              width: 80,
+              transform: [{ translateX: 6 }],
+              justifyContent: 'center',
+            },
+            style,
+            focusedStyle,
+            !focused && {
+              opacity: 0,
+              position: 'absolute',
+              top: 0,
+            },
+          ]}
+          textStyle={[{ fontSize: 15, textAlign: 'right' }, textStyle]}
+        />
+
+        <View>
+          {!focused && (
+            <Button
+              style={{
+                position: 'absolute',
+                right: 'calc(100% + 5px)',
+                top: '8px',
+              }}
+              onClick={this.toggleIsNegative}
+            >
+              {this.state.isNegative ? '−' : '+'}
+            </Button>
+          )}
+          <Button
+            onClick={this.onFocus}
+            // Defines how far touch can start away from the button
+            // hitSlop={{ top: 5, bottom: 5, left: 5, right: 5 }}
+            {...buttonProps}
+            style={[
+              buttonProps && buttonProps.style,
+              focused && { display: 'none' },
+              {
+                ':hover': {
+                  backgroundColor: 'transparent',
+                },
+              },
+            ]}
+            type="bare"
+          >
+            <View
+              style={[
+                {
+                  borderBottomWidth: 1,
+                  borderColor: '#e0e0e0',
+                  justifyContent: 'center',
+                  transform: [{ translateY: 0.5 }],
+                },
+                style,
+              ]}
+            >
+              <Text style={[{ fontSize: 15, userSelect: 'none' }, textStyle]}>
+                {amountToCurrency(Math.abs(this.props.value))}
+              </Text>
+            </View>
+          </Button>
+        </View>
+      </View>
+    );
+  }
+}
diff --git a/packages/desktop-client/src/components/mobile/MobileForms.js b/packages/desktop-client/src/components/mobile/MobileForms.js
new file mode 100644
index 000000000..67757db04
--- /dev/null
+++ b/packages/desktop-client/src/components/mobile/MobileForms.js
@@ -0,0 +1,127 @@
+import { forwardRef } from 'react';
+
+import { css } from 'glamor';
+
+import { theme } from '../../style';
+import Button from '../common/Button';
+import Input from '../common/Input';
+import Text from '../common/Text';
+import View from '../common/View';
+
+export const EDITING_PADDING = 12;
+const FIELD_HEIGHT = 40;
+
+export function FieldLabel({ title, flush, style }) {
+  return (
+    <Text
+      style={[
+        {
+          marginBottom: 5,
+          marginTop: flush ? 0 : 25,
+          fontSize: 13,
+          color: theme.tableRowHeaderText,
+          paddingLeft: EDITING_PADDING,
+          textTransform: 'uppercase',
+          userSelect: 'none',
+        },
+        style,
+      ]}
+    >
+      {title}
+    </Text>
+  );
+}
+
+const valueStyle = {
+  borderWidth: 1,
+  borderColor: theme.formInputBorder,
+  marginLeft: -1,
+  marginRight: -1,
+  height: FIELD_HEIGHT,
+  paddingHorizontal: EDITING_PADDING,
+};
+
+export const InputField = forwardRef(function InputField(
+  { disabled, style, onUpdate, ...props },
+  ref,
+) {
+  return (
+    <Input
+      ref={ref}
+      autoCorrect="false"
+      autoCapitalize="none"
+      disabled={disabled}
+      onBlur={e => {
+        onUpdate?.(e.target.value);
+      }}
+      style={[
+        valueStyle,
+        style,
+        {
+          backgroundColor: disabled
+            ? theme.formInputTextReadOnlySelection
+            : 'white',
+        },
+      ]}
+      {...props}
+    />
+  );
+});
+
+export function TapField({
+  value,
+  children,
+  disabled,
+  rightContent,
+  style,
+  textStyle,
+  onClick,
+}) {
+  return (
+    <Button
+      as={View}
+      onClick={!disabled ? onClick : undefined}
+      style={[
+        { flexDirection: 'row', alignItems: 'center' },
+        style,
+        valueStyle,
+        { backgroundColor: 'white' },
+        disabled && { backgroundColor: theme.formInputTextReadOnlySelection },
+      ]}
+      bounce={false}
+      activeStyle={{
+        opacity: 0.5,
+        boxShadow: 'none',
+      }}
+      hoveredStyle={{
+        boxShadow: 'none',
+      }}
+      // activeOpacity={0.05}
+    >
+      {children ? (
+        children
+      ) : (
+        <Text style={[{ flex: 1, userSelect: 'none' }, textStyle]}>
+          {value}
+        </Text>
+      )}
+      {!disabled && rightContent}
+    </Button>
+  );
+}
+
+export function BooleanField({ checked, onUpdate, style }) {
+  return (
+    <input
+      type="checkbox"
+      checked={checked}
+      onChange={e => onUpdate(e.target.checked)}
+      {...css([
+        {
+          marginInline: EDITING_PADDING,
+        },
+        style,
+      ])}
+    />
+  );
+}
diff --git a/packages/desktop-client/src/components/modals/EditField.js b/packages/desktop-client/src/components/modals/EditField.js
index faff7a261..f8eb44e46 100644
--- a/packages/desktop-client/src/components/modals/EditField.js
+++ b/packages/desktop-client/src/components/modals/EditField.js
@@ -7,6 +7,7 @@ import { currentDay, dayFromDate } from 'loot-core/src/shared/months';
 import { amountToInteger } from 'loot-core/src/shared/util';
 
 import { useActions } from '../../hooks/useActions';
+import { useResponsive } from '../../ResponsiveProvider';
 import { colors } from '../../style';
 import AccountAutocomplete from '../autocomplete/AccountAutocomplete';
 import CategoryAutocomplete from '../autocomplete/CategorySelect';
@@ -39,11 +40,12 @@ export default function EditField({ modalProps, name, onSubmit }) {
     modalProps.onClose();
   }
 
+  const { isNarrowWidth } = useResponsive();
   let label, editor, minWidth;
   let inputStyle = { ':focus': { boxShadow: 0 } };
   let autocompleteProps = {
     inputProps: { style: inputStyle },
-    containerProps: { style: { height: 275 } },
+    containerProps: { style: { height: isNarrowWidth ? '90vh' : 275 } },
   };
 
   switch (name) {
@@ -74,11 +76,19 @@ export default function EditField({ modalProps, name, onSubmit }) {
           accounts={accounts}
           focused={true}
           embedded={true}
+          closeOnBlur={false}
           onSelect={value => {
             if (value) {
               onSelect(value);
             }
           }}
+          groupHeaderStyle={
+            isNarrowWidth
+              ? {
+                  color: colors.n6,
+                }
+              : undefined
+          }
           {...autocompleteProps}
         />
       );
@@ -93,7 +103,9 @@ export default function EditField({ modalProps, name, onSubmit }) {
           value={null}
           focused={true}
           embedded={true}
+          closeOnBlur={false}
           showManagePayees={false}
+          showMakeTransfer={!isNarrowWidth}
           onSelect={async value => {
             if (value && value.startsWith('new:')) {
               value = await createPayee(value.slice('new:'.length));
@@ -102,6 +114,13 @@ export default function EditField({ modalProps, name, onSubmit }) {
             onSelect(value);
           }}
           isCreatable
+          groupHeaderStyle={
+            isNarrowWidth
+              ? {
+                  color: colors.n6,
+                }
+              : undefined
+          }
           {...autocompleteProps}
         />
       );
@@ -126,11 +145,19 @@ export default function EditField({ modalProps, name, onSubmit }) {
           value={null}
           focused={true}
           embedded={true}
+          closeOnBlur={false}
           showSplitOption={false}
           onUpdate={() => {}}
           onSelect={value => {
             onSelect(value);
           }}
+          groupHeaderStyle={
+            isNarrowWidth
+              ? {
+                  color: colors.n6,
+                }
+              : undefined
+          }
           {...autocompleteProps}
         />
       );
@@ -152,31 +179,35 @@ export default function EditField({ modalProps, name, onSubmit }) {
 
   return (
     <Modal
-      noAnimation={true}
-      showHeader={false}
+      title={label}
+      noAnimation={!isNarrowWidth}
+      showHeader={isNarrowWidth}
       focusAfterClose={false}
       {...modalProps}
       padding={0}
       style={[
         {
           flex: 0,
+          height: isNarrowWidth ? '85vh' : 275,
           padding: '15px 10px',
-          backgroundColor: colors.n1,
-          color: 'white',
+          borderRadius: '6px',
         },
         minWidth && { minWidth },
+        !isNarrowWidth && { backgroundColor: colors.n1, color: 'white' },
       ]}
     >
       {() => (
         <View>
-          <SectionLabel
-            title={label}
-            style={{
-              alignSelf: 'center',
-              color: colors.b10,
-              marginBottom: 10,
-            }}
-          />
+          {!isNarrowWidth && (
+            <SectionLabel
+              title={label}
+              style={{
+                alignSelf: 'center',
+                color: colors.b10,
+                marginBottom: 10,
+              }}
+            />
+          )}
           <View style={{ flex: 1 }}>{editor}</View>
         </View>
       )}
diff --git a/packages/desktop-client/src/components/transactions/MobileTransaction.js b/packages/desktop-client/src/components/transactions/MobileTransaction.js
index bc7896786..d5d3b66b6 100644
--- a/packages/desktop-client/src/components/transactions/MobileTransaction.js
+++ b/packages/desktop-client/src/components/transactions/MobileTransaction.js
@@ -3,31 +3,66 @@ import React, {
   Component,
   forwardRef,
   useEffect,
+  useState,
   useRef,
 } from 'react';
+import { connect } from 'react-redux';
+import { useNavigate, useParams, Link } from 'react-router-dom';
 
 import { useFocusRing } from '@react-aria/focus';
 import { useListBox, useListBoxSection, useOption } from '@react-aria/listbox';
 import { mergeProps } from '@react-aria/utils';
 import { Item, Section } from '@react-stately/collections';
 import { useListState } from '@react-stately/list';
+import {
+  format as formatDate,
+  parse as parseDate,
+  parseISO,
+  isValid as isValidDate,
+} from 'date-fns';
 import { css } from 'glamor';
 import memoizeOne from 'memoize-one';
 
+import * as actions from 'loot-core/src/client/actions';
+import q, { runQuery } from 'loot-core/src/client/query-helpers';
+import { send } from 'loot-core/src/platform/client/fetch';
 import * as monthUtils from 'loot-core/src/shared/months';
 import { getScheduledAmount } from 'loot-core/src/shared/schedules';
+import {
+  ungroupTransactions,
+  updateTransaction,
+  realizeTempTransactions,
+} from 'loot-core/src/shared/transactions';
 import {
   titleFirst,
   integerToCurrency,
+  integerToAmount,
+  amountToInteger,
+  getChangedValues,
+  diffItems,
   groupById,
 } from 'loot-core/src/shared/util';
 
+import { useSetThemeColor } from '../../hooks/useSetThemeColor';
+import SvgAdd from '../../icons/v1/Add';
+import CheveronLeft from '../../icons/v1/CheveronLeft';
+import SvgTrash from '../../icons/v1/Trash';
 import ArrowsSynchronize from '../../icons/v2/ArrowsSynchronize';
 import CheckCircle1 from '../../icons/v2/CheckCircle1';
-import { styles, colors } from '../../style';
+import SvgPencilWriteAlternate from '../../icons/v2/PencilWriteAlternate';
+import { styles, colors, theme } from '../../style';
+import Button from '../common/Button';
 import Text from '../common/Text';
 import TextOneLine from '../common/TextOneLine';
 import View from '../common/View';
+import { FocusableAmountInput } from '../mobile/MobileAmountInput';
+import {
+  FieldLabel,
+  TapField,
+  InputField,
+  BooleanField,
+  EDITING_PADDING,
+} from '../mobile/MobileForms';
 
 const zIndices = { SECTION_HEADING: 10 };
 
@@ -50,6 +85,50 @@ function getDescriptionPretty(transaction, payee, transferAcct) {
   return '';
 }
 
+function serializeTransaction(transaction, dateFormat) {
+  let { date, amount } = transaction;
+  return {
+    ...transaction,
+    date: formatDate(parseISO(date), dateFormat),
+    amount: integerToAmount(amount || 0),
+  };
+}
+
+function deserializeTransaction(transaction, originalTransaction, dateFormat) {
+  let { amount, date, ...realTransaction } = transaction;
+
+  let dayMonth = monthUtils.getDayMonthRegex(dateFormat);
+  if (dayMonth.test(date)) {
+    let test = parseDate(
+      date,
+      monthUtils.getDayMonthFormat(dateFormat),
+      new Date(),
+    );
+    if (isValidDate(test)) {
+      date = monthUtils.dayFromDate(test);
+    } else {
+      date = null;
+    }
+  } else {
+    let test = parseDate(date, dateFormat, new Date());
+    // This is a quick sanity check to make sure something invalid
+    // like "year 201" was entered
+    if (test.getFullYear() > 2000 && isValidDate(test)) {
+      date = monthUtils.dayFromDate(test);
+    } else {
+      date = null;
+    }
+  }
+
+  if (date == null) {
+    date =
+      (originalTransaction && originalTransaction.date) ||
+      monthUtils.currentDay();
+  }
+
+  return { ...realTransaction, date, amount: amountToInteger(amount || 0) };
+}
+
 function lookupName(items, id) {
   return items.find(item => item.id === id).name;
 }
@@ -102,6 +181,679 @@ function Status({ status }) {
   );
 }
 
+const LEFT_RIGHT_FLEX_WIDTH = 70;
+class TransactionEditInner extends PureComponent {
+  constructor(props) {
+    super(props);
+    this.state = {
+      transactions: props.transactions,
+      editingChild: null,
+    };
+  }
+
+  serializeTransactions = memoizeOne(transactions => {
+    return transactions.map(t =>
+      serializeTransaction(t, this.props.dateFormat),
+    );
+  });
+
+  componentDidMount() {
+    if (this.props.adding) {
+      this.amount.focus();
+    }
+  }
+
+  componentWillUnmount() {
+    document
+      .querySelector('meta[name="theme-color"]')
+      .setAttribute('content', '#ffffff');
+  }
+
+  openChildEdit = child => {
+    this.setState({ editingChild: child.id });
+  };
+
+  onAdd = () => {
+    this.onSave();
+  };
+
+  onSave = async () => {
+    let { transactions } = this.state;
+    const [transaction, ..._childTransactions] = transactions;
+    const { account: accountId } = transaction;
+    let account = getAccountsById(this.props.accounts)[accountId];
+
+    if (transactions.find(t => t.account == null)) {
+      // Ignore transactions if any of them don't have an account
+      return;
+    }
+
+    // Since we don't own the state, we have to handle the case where
+    // the user saves while editing an input. We won't have the
+    // updated value so we "apply" a queued change. Maybe there's a
+    // better way to do this (lift the state?)
+    if (this._queuedChange) {
+      let [transaction, name, value] = this._queuedChange;
+      transactions = await this.onEdit(transaction, name, value);
+    }
+
+    if (this.props.adding) {
+      transactions = realizeTempTransactions(transactions);
+    }
+
+    this.props.onSave(transactions);
+    this.props.navigation(`/accounts/${account.id}`);
+  };
+
+  onSaveChild = childTransaction => {
+    this.setState({ editingChild: null });
+  };
+
+  onEdit = async (transaction, name, value) => {
+    let { transactions } = this.state;
+
+    let newTransaction = { ...transaction, [name]: value };
+    if (this.props.onEdit) {
+      newTransaction = await this.props.onEdit(newTransaction);
+    }
+
+    let { data: newTransactions } = updateTransaction(
+      transactions,
+      deserializeTransaction(newTransaction, null, this.props.dateFormat),
+    );
+
+    this._queuedChange = null;
+    this.setState({ transactions: newTransactions });
+    return newTransactions;
+  };
+
+  onQueueChange = (transaction, name, value) => {
+    // This is an ugly hack to solve the problem that input's blur
+    // events are not fired when unmounting. If the user has focused
+    // an input and swipes back, it should still save, but because the
+    // blur event is not fired we need to manually track the latest
+    // change and apply it ourselves when unmounting
+    this._queuedChange = [transaction, name, value];
+  };
+
+  onClick = (transactionId, name) => {
+    let { dateFormat } = this.props;
+
+    this.props.pushModal('edit-field', {
+      name,
+      onSubmit: (name, value) => {
+        let { transactions } = this.state;
+        let transaction = transactions.find(t => t.id === transactionId);
+        // This is a deficiency of this API, need to fix. It
+        // assumes that it receives a serialized transaction,
+        // but we only have access to the raw transaction
+        this.onEdit(serializeTransaction(transaction, dateFormat), name, value);
+      },
+    });
+  };
+
+  render() {
+    const {
+      adding,
+      categories,
+      accounts,
+      payees,
+      renderChildEdit,
+      navigation,
+      onDelete,
+    } = this.props;
+    const { editingChild } = this.state;
+    const transactions = this.serializeTransactions(
+      this.state.transactions || [],
+    );
+    const [transaction, ..._childTransactions] = transactions;
+    const { payee: payeeId, category, account: accountId } = transaction;
+
+    // Child transactions should always default to the signage
+    // of the parent transaction
+    let forcedSign = transaction.amount < 0 ? 'negative' : 'positive';
+
+    let account = getAccountsById(accounts)[accountId];
+    let payee = payees && payeeId && getPayeesById(payees)[payeeId];
+    let transferAcct =
+      payee &&
+      payee.transfer_acct &&
+      getAccountsById(accounts)[payee.transfer_acct];
+
+    let descriptionPretty = getDescriptionPretty(
+      transaction,
+      payee,
+      transferAcct,
+    );
+
+    const transactionDate = parseDate(
+      transaction.date,
+      this.props.dateFormat,
+      new Date(),
+    );
+    const dateDefaultValue = monthUtils.dayFromDate(transactionDate);
+
+    return (
+      // <KeyboardAvoidingView>
+      <View
+        style={{
+          margin: 10,
+          marginTop: 3,
+          backgroundColor: colors.n11,
+          flex: 1,
+          borderRadius: 4,
+
+          // This shadow make the card "pop" off of the screen below
+          // it
+          shadowColor: colors.n3,
+          shadowOffset: { width: 0, height: 0 },
+          shadowRadius: 4,
+          shadowOpacity: 1,
+        }}
+      >
+        <View
+          style={{
+            borderRadius: 4,
+            overflow: 'hidden',
+            display: 'flex',
+            flex: 'auto',
+          }}
+        >
+          <View
+            style={{
+              borderBottomWidth: 1,
+              borderColor: colors.n9,
+              backgroundColor: 'white',
+              alignItems: 'center',
+              flexDirection: 'row',
+              flexShrink: 0,
+              justifyContent: 'space-between',
+              width: '100%',
+              padding: 10,
+            }}
+          >
+            <Link
+              to={`/accounts/${account.id}`}
+              style={{
+                alignItems: 'center',
+                display: 'flex',
+                textDecoration: 'none',
+                width: LEFT_RIGHT_FLEX_WIDTH,
+              }}
+            >
+              <CheveronLeft
+                style={{
+                  color: colors.b5,
+                  width: 32,
+                  height: 32,
+                }}
+              />
+              <Text
+                style={{ ...styles.text, color: colors.b5, fontWeight: 500 }}
+              >
+                Back
+              </Text>
+            </Link>
+            <TextOneLine
+              style={{
+                color: theme.formInputText,
+                fontSize: 15,
+                fontWeight: 600,
+                userSelect: 'none',
+              }}
+            >
+              {payeeId == null
+                ? adding
+                  ? 'New Transaction'
+                  : 'Transaction'
+                : descriptionPretty}
+            </TextOneLine>
+            {/* For centering the transaction title */}
+            <View
+              style={{
+                width: LEFT_RIGHT_FLEX_WIDTH,
+              }}
+            />
+          </View>
+
+          {/* <ScrollView
+            ref={el => (this.scrollView = el)}
+            automaticallyAdjustContentInsets={false}
+            keyboardShouldPersistTaps="always"
+            style={{
+              backgroundColor: colors.n11,
+              flexGrow: 1,
+              overflow: 'hidden',
+            }}
+            contentContainerStyle={{ flexGrow: 1 }}
+          > */}
+          <View
+            style={{
+              overflowY: 'auto',
+              overflowX: 'hidden',
+              display: 'block',
+            }}
+          >
+            <View
+              style={{
+                alignItems: 'center',
+                marginTop: 20,
+              }}
+            >
+              <FieldLabel
+                title="Amount"
+                flush
+                style={{ marginBottom: 0, paddingLeft: 0 }}
+              />
+              <FocusableAmountInput
+                ref={el => (this.amount = el)}
+                value={transaction.amount}
+                zeroIsNegative={true}
+                onBlur={value =>
+                  this.onEdit(transaction, 'amount', value.toString())
+                }
+                onChange={value =>
+                  this.onQueueChange(transaction, 'amount', value)
+                }
+                style={{ transform: [] }}
+                focusedStyle={{
+                  width: 'auto',
+                  padding: '5px',
+                  paddingLeft: '20px',
+                  paddingRight: '20px',
+                  minWidth: 120,
+                  transform: [{ translateY: -0.5 }],
+                }}
+                textStyle={{ fontSize: 30, textAlign: 'center' }}
+              />
+            </View>
+
+            <View>
+              <FieldLabel title="Payee" />
+              <TapField
+                value={descriptionPretty}
+                onClick={() => this.onClick(transaction.id, 'payee')}
+              />
+            </View>
+
+            <View>
+              <FieldLabel
+                title={
+                  transaction.is_parent ? 'Categories (split)' : 'Category'
+                }
+              />
+              {!transaction.is_parent ? (
+                <TapField
+                  value={category ? lookupName(categories, category) : null}
+                  disabled={(account && !!account.offbudget) || transferAcct}
+                  // TODO: the button to turn this transaction into a split
+                  // transaction was on top of the category button in the native
+                  // app, on the right-hand side
+                  //
+                  // On the web this doesn't work well and react gets upset if
+                  // nest a button in a button.
+                  //
+                  // rightContent={
+                  //   <Button
+                  //     contentStyle={{
+                  //       paddingVertical: 4,
+                  //       paddingHorizontal: 15,
+                  //       margin: 0,
+                  //     }}
+                  //     onPress={this.onSplit}
+                  //   >
+                  //     Split
+                  //   </Button>
+                  // }
+                  onClick={() => this.onClick(transaction.id, 'category')}
+                />
+              ) : (
+                <Text style={{ paddingLeft: EDITING_PADDING }}>
+                  Split transaction editing is not supported on mobile at this
+                  time.
+                </Text>
+              )}
+            </View>
+
+            <View>
+              <FieldLabel title="Account" />
+              <TapField
+                disabled={!adding}
+                value={account ? account.name : null}
+                onClick={() => this.onClick(transaction.id, 'account')}
+              />
+            </View>
+
+            <View style={{ flexDirection: 'row' }}>
+              <View style={{ flex: 1 }}>
+                <FieldLabel title="Date" />
+                <InputField
+                  type="date"
+                  required
+                  style={{ color: 'canvastext', minWidth: '150px' }}
+                  defaultValue={dateDefaultValue}
+                  onUpdate={value =>
+                    this.onEdit(
+                      transaction,
+                      'date',
+                      formatDate(parseISO(value), this.props.dateFormat),
+                    )
+                  }
+                  onChange={e =>
+                    this.onQueueChange(
+                      transaction,
+                      'date',
+                      formatDate(
+                        parseISO(e.target.value),
+                        this.props.dateFormat,
+                      ),
+                    )
+                  }
+                />
+              </View>
+
+              <View style={{ marginLeft: 35, marginRight: 35 }}>
+                <FieldLabel title="Cleared" />
+                <BooleanField
+                  checked={transaction.cleared}
+                  onUpdate={checked =>
+                    this.onEdit(transaction, 'cleared', checked)
+                  }
+                  style={{ marginTop: 4 }}
+                />
+              </View>
+            </View>
+
+            <View>
+              <FieldLabel title="Notes" />
+              <InputField
+                defaultValue={transaction.notes}
+                onUpdate={value => this.onEdit(transaction, 'notes', value)}
+                onChange={e =>
+                  this.onQueueChange(transaction, 'notes', e.target.value)
+                }
+              />
+            </View>
+
+            {!adding && (
+              <View style={{ alignItems: 'center' }}>
+                <Button
+                  onClick={() => onDelete()}
+                  style={{
+                    borderWidth: 0,
+                    paddingVertical: 5,
+                    marginLeft: EDITING_PADDING,
+                    marginRight: EDITING_PADDING,
+                    marginTop: 20,
+                    marginBottom: 15,
+                    backgroundColor: 'transparent',
+                  }}
+                  type="bare"
+                >
+                  <SvgTrash
+                    width={17}
+                    height={17}
+                    style={{ color: colors.r4 }}
+                  />
+                  <Text
+                    style={{
+                      color: colors.r4,
+                      marginLeft: 5,
+                      userSelect: 'none',
+                    }}
+                  >
+                    Delete transaction
+                  </Text>
+                </Button>
+              </View>
+            )}
+          </View>
+
+          <View
+            style={{
+              paddingLeft: EDITING_PADDING,
+              paddingRight: EDITING_PADDING,
+              paddingTop: 15,
+              paddingBottom: 15,
+              backgroundColor: colors.n11,
+              borderTopWidth: 1,
+              borderColor: colors.n10,
+              marginTop: 'auto',
+              flexShrink: 0,
+            }}
+          >
+            {adding ? (
+              <Button onClick={() => this.onAdd()}>
+                <SvgAdd width={17} height={17} style={{ color: colors.b3 }} />
+                <Text
+                  style={[styles.text, { color: colors.b3, marginLeft: 5 }]}
+                >
+                  Add transaction
+                </Text>
+              </Button>
+            ) : (
+              <Button onClick={() => this.onSave()}>
+                <SvgPencilWriteAlternate
+                  style={{ width: 16, height: 16, color: colors.n1 }}
+                />
+                <Text
+                  style={[styles.text, { marginLeft: 6, color: colors.n1 }]}
+                >
+                  Save changes
+                </Text>
+              </Button>
+            )}
+          </View>
+
+          {/* <ExitTransition
+            alive={editingChild}
+            withProps={{
+              transaction:
+                editingChild && transactions.find(t => t.id === editingChild),
+            }}
+          > */}
+          {renderChildEdit({
+            transaction:
+              editingChild && transactions.find(t => t.id === editingChild),
+            amountSign: forcedSign,
+            getCategoryName: id => (id ? lookupName(categories, id) : null),
+            navigation: navigation,
+            onEdit: this.onEdit,
+            onStartClose: this.onSaveChild,
+          })}
+          {/* </ExitTransition> */}
+        </View>
+      </View>
+      // </KeyboardAvoidingView>
+    );
+  }
+}
+
+function isTemporary(transaction) {
+  return transaction.id.indexOf('temp') === 0;
+}
+
+function makeTemporaryTransactions(currentAccountId, lastDate) {
+  return [
+    {
+      id: 'temp',
+      date: lastDate || monthUtils.currentDay(),
+      account: currentAccountId,
+      amount: 0,
+      cleared: false,
+    },
+  ];
+}
+
+function TransactionEditUnconnected(props) {
+  const { categories, accounts, payees, lastTransaction, dateFormat } = props;
+  let { id: accountId, transactionId } = useParams();
+  let navigate = useNavigate();
+  let [fetchedTransactions, setFetchedTransactions] = useState(null);
+  let transactions = [];
+  let adding = false;
+  let deleted = false;
+
+  useSetThemeColor(colors.p5);
+
+  useEffect(() => {
+    // May as well update categories / accounts when transaction ID changes
+    props.getCategories();
+    props.getAccounts();
+    props.getPayees();
+
+    async function fetchTransaction() {
+      let transactions = [];
+      if (transactionId) {
+        // Query for the transaction based on the ID with grouped splits.
+        //
+        // This means if the transaction in question is a split transaction, its
+        // subtransactions will be returned in the `substransactions` property on
+        // the parent transaction.
+        //
+        // The edit item components expect to work with a flat array of
+        // transactions when handling splits, so we call ungroupTransactions to
+        // flatten parent and children into one array.
+        let { data } = await runQuery(
+          q('transactions')
+            .filter({ id: transactionId })
+            .select('*')
+            .options({ splits: 'grouped' }),
+        );
+        transactions = ungroupTransactions(data);
+        setFetchedTransactions(transactions);
+      }
+    }
+    fetchTransaction();
+  }, [transactionId]);
+
+  if (
+    categories.length === 0 ||
+    accounts.length === 0 ||
+    (transactionId && !fetchedTransactions)
+  ) {
+    return null;
+  }
+
+  if (!transactionId) {
+    transactions = makeTemporaryTransactions(
+      accountId || (lastTransaction && lastTransaction.account) || null,
+      lastTransaction && lastTransaction.date,
+    );
+    adding = true;
+  } else {
+    transactions = fetchedTransactions;
+  }
+
+  const onEdit = async transaction => {
+    // Run the rules to auto-fill in any data. Right now we only do
+    // this on new transactions because that's how desktop works.
+    if (isTemporary(transaction)) {
+      let afterRules = await send('rules-run', { transaction });
+      let diff = getChangedValues(transaction, afterRules);
+
+      let newTransaction = { ...transaction };
+      if (diff) {
+        Object.keys(diff).forEach(field => {
+          if (newTransaction[field] == null) {
+            newTransaction[field] = diff[field];
+          }
+        });
+      }
+      return newTransaction;
+    }
+
+    return transaction;
+  };
+
+  const onSave = async newTransactions => {
+    if (deleted) {
+      return;
+    }
+
+    const changes = diffItems(transactions || [], newTransactions);
+    if (
+      changes.added.length > 0 ||
+      changes.updated.length > 0 ||
+      changes.deleted.length
+    ) {
+      const _remoteUpdates = await send('transactions-batch-update', {
+        added: changes.added,
+        deleted: changes.deleted,
+        updated: changes.updated,
+      });
+
+      // if (onTransactionsChange) {
+      //   onTransactionsChange({
+      //     ...changes,
+      //     updated: changes.updated.concat(remoteUpdates),
+      //   });
+      // }
+    }
+
+    if (adding) {
+      // The first one is always the "parent" and the only one we care
+      // about
+      props.setLastTransaction(newTransactions[0]);
+    }
+  };
+
+  const onDelete = async () => {
+    // Eagerly go back
+    navigate(`/accounts/${accountId}`);
+
+    if (adding) {
+      // Adding a new transactions, this disables saving when the component unmounts
+      deleted = true;
+    } else {
+      const changes = { deleted: transactions };
+      const _remoteUpdates = await send('transactions-batch-update', changes);
+      // if (onTransactionsChange) {
+      //   onTransactionsChange({ ...changes, updated: remoteUpdates });
+      // }
+    }
+  };
+
+  return (
+    <View
+      style={{
+        flex: 1,
+        backgroundColor: colors.p5,
+      }}
+    >
+      <TransactionEditInner
+        transactions={transactions}
+        adding={adding}
+        categories={categories}
+        accounts={accounts}
+        payees={payees}
+        pushModal={props.pushModal}
+        navigation={navigate}
+        // TODO: ChildEdit is complicated and heavily relies on RN
+        // renderChildEdit={props => <ChildEdit {...props} />}
+        renderChildEdit={props => {}}
+        dateFormat={dateFormat}
+        // TODO: was this a mistake in the original code?
+        // onTapField={this.onTapField}
+        onEdit={onEdit}
+        onSave={onSave}
+        onDelete={onDelete}
+      />
+    </View>
+  );
+}
+
+export const TransactionEdit = connect(
+  state => ({
+    categories: state.queries.categories.list,
+    payees: state.queries.payees,
+    lastTransaction: state.queries.lastTransaction,
+    accounts: state.queries.accounts,
+    dateFormat: state.prefs.local.dateFormat || 'MM/dd/yyyy',
+  }),
+  actions,
+)(TransactionEditUnconnected);
+
 class Transaction extends PureComponent {
   render() {
     const {
@@ -111,7 +863,7 @@ class Transaction extends PureComponent {
       payees,
       showCategory,
       added,
-      // onSelect,
+      onSelect,
       style,
     } = this.props;
     let {
@@ -155,94 +907,94 @@ class Transaction extends PureComponent {
     };
 
     return (
-      // <Button
-      //   onClick={() => onSelect(transaction)}
-      //   style={{
-      //     backgroundColor: 'white',
-      //     border: 'none',
-      //     width: '100%',
-      //     '&:active': { opacity: 0.1 }
-      //   }}
-      // >
-      <ListItem
-        style={[
-          { flex: 1, height: 60, padding: '5px 10px' }, // remove padding when Button is back
-          isPreview && { backgroundColor: colors.n11 },
-          style,
-        ]}
+      <Button
+        onClick={() => onSelect(transaction)}
+        style={{
+          backgroundColor: 'white',
+          border: 'none',
+          width: '100%',
+          '&:active': { opacity: 0.1 },
+        }}
       >
-        <View style={[{ flex: 1 }]}>
-          <View style={{ flexDirection: 'row', alignItems: 'center' }}>
-            {schedule && (
-              <ArrowsSynchronize
-                style={{
-                  width: 12,
-                  height: 12,
-                  marginRight: 5,
-                  color: textStyle.color || colors.n1,
-                }}
-              />
-            )}
-            <TextOneLine
-              style={[
-                styles.text,
-                textStyle,
-                { fontSize: 14, fontWeight: added ? '600' : '400' },
-                prettyDescription === '' && {
-                  color: colors.n6,
-                  fontStyle: 'italic',
-                },
-              ]}
-            >
-              {prettyDescription || 'Empty'}
-            </TextOneLine>
-          </View>
-          {isPreview ? (
-            <Status status={notes} />
-          ) : (
-            <View
-              style={{
-                flexDirection: 'row',
-                alignItems: 'center',
-                marginTop: 3,
-              }}
-            >
-              <CheckCircle1
-                style={{
-                  width: 11,
-                  height: 11,
-                  color: cleared ? colors.g6 : colors.n8,
-                  marginRight: 5,
-                }}
-              />
-              {showCategory && (
-                <TextOneLine
+        <ListItem
+          style={[
+            { flex: 1, height: 60, padding: '5px 10px' }, // remove padding when Button is back
+            isPreview && { backgroundColor: colors.n11 },
+            style,
+          ]}
+        >
+          <View style={[{ flex: 1 }]}>
+            <View style={{ flexDirection: 'row', alignItems: 'center' }}>
+              {schedule && (
+                <ArrowsSynchronize
                   style={{
-                    fontSize: 11,
-                    marginTop: 1,
-                    fontWeight: '400',
-                    color: prettyCategory ? colors.n3 : colors.p7,
-                    fontStyle: prettyCategory ? null : 'italic',
-                    textAlign: 'left',
+                    width: 12,
+                    height: 12,
+                    marginRight: 5,
+                    color: textStyle.color || colors.n1,
                   }}
-                >
-                  {prettyCategory || 'Uncategorized'}
-                </TextOneLine>
+                />
               )}
+              <TextOneLine
+                style={[
+                  styles.text,
+                  textStyle,
+                  { fontSize: 14, fontWeight: added ? '600' : '400' },
+                  prettyDescription === '' && {
+                    color: colors.n6,
+                    fontStyle: 'italic',
+                  },
+                ]}
+              >
+                {prettyDescription || 'Empty'}
+              </TextOneLine>
             </View>
-          )}
-        </View>
-        <Text
-          style={[
-            styles.text,
-            textStyle,
-            { marginLeft: 25, marginRight: 5, fontSize: 14 },
-          ]}
-        >
-          {integerToCurrency(amount)}
-        </Text>
-      </ListItem>
-      // </Button>
+            {isPreview ? (
+              <Status status={notes} />
+            ) : (
+              <View
+                style={{
+                  flexDirection: 'row',
+                  alignItems: 'center',
+                  marginTop: 3,
+                }}
+              >
+                <CheckCircle1
+                  style={{
+                    width: 11,
+                    height: 11,
+                    color: cleared ? colors.g6 : colors.n8,
+                    marginRight: 5,
+                  }}
+                />
+                {showCategory && (
+                  <TextOneLine
+                    style={{
+                      fontSize: 11,
+                      marginTop: 1,
+                      fontWeight: '400',
+                      color: prettyCategory ? colors.n3 : colors.p7,
+                      fontStyle: prettyCategory ? null : 'italic',
+                      textAlign: 'left',
+                    }}
+                  >
+                    {prettyCategory || 'Uncategorized'}
+                  </TextOneLine>
+                )}
+              </View>
+            )}
+          </View>
+          <Text
+            style={[
+              styles.text,
+              textStyle,
+              { marginLeft: 25, marginRight: 5, fontSize: 14 },
+            ]}
+          >
+            {integerToCurrency(amount)}
+          </Text>
+        </ListItem>
+      </Button>
     );
   }
 }
@@ -337,7 +1089,7 @@ export class TransactionList extends Component {
                         payees={this.props.payees}
                         showCategory={this.props.showCategory}
                         added={this.props.isNew(transaction.id)}
-                        onSelect={() => {}} // onSelect(transaction)}
+                        onSelect={this.props.onSelect} // onSelect(transaction)}
                       />
                     </Item>
                   );
diff --git a/packages/loot-core/src/shared/transactions.ts b/packages/loot-core/src/shared/transactions.ts
index ac5675b5a..967532554 100644
--- a/packages/loot-core/src/shared/transactions.ts
+++ b/packages/loot-core/src/shared/transactions.ts
@@ -70,7 +70,7 @@ function findParentIndex(transactions, idx) {
   return null;
 }
 
-export function getSplit(transactions, parentIndex) {
+function getSplit(transactions, parentIndex) {
   let split = [transactions[parentIndex]];
   let curr = parentIndex + 1;
   while (curr < transactions.length && transactions[curr].is_child) {
diff --git a/upcoming-release-notes/1340.md b/upcoming-release-notes/1340.md
new file mode 100644
index 000000000..1eed73f32
--- /dev/null
+++ b/upcoming-release-notes/1340.md
@@ -0,0 +1,6 @@
+---
+category: Features
+authors: [Cldfire]
+---
+
+Add editing / adding transactions on mobile devices (via an initial port of the old React Native UI)
-- 
GitLab