Недавно я запустил свое первое приложение для Android: inCourse — финансовый менеджер, написанное на React Native (RN). По основному роду занятий я инженер. Первый опыт программирования получил в конце 2000-х, когда учился в институте. Тогда я был увлечен написанием игры на Visual Basic 6.0, самоучитель по которому как‑то оказался у нас дома. Летом 2021 г., вдохновленный опытом друга, записался на курсы по программированию и получил сертификаты об успешном окончании курсов по JavaScript/Fronend и React в 2022 г.
Здесь хотелось бы сделать небольшое отступление и обратить ваше внимание, что статья несет информацию о личном опыте автора и не претендует на статус обучающего материала, «best practice» либо аналитической статьи. Если такой формат вас устраивает, то продолжим.
Идея написать финансовый менеджер в качестве пет‑проекта пришла мне в июле 2022 г. В сфере управления личными финансами у меня уже имелся достаточный опыт (в т.ч. опыт торговли акциями, валютными парами, ПФИ и проч.), и было понимание важности единого учета финансов для их анализа и управления. Учет я вел в Excel, но хотелось создать что‑то более удобное в красивой упаковке. Опыта программирования на RN у меня к тому моменту не было, и я решил изучать его по ходу разработки на конкретных задачах проекта. Изучение документации RN не заняло много времени — в основе своей это тот же React с некоторыми упрощениями.
Техническое задание
На начальном этапе я уже имел примерное представление, что хочу создать, но часть критериев добавлялась уже по ходу. Конечно, доработки требовали времени, но гибкость в разработке — один из принципов методологии «agile», что несколько придавало воодушевления. Итак, ниже приведены основные критерии моего приложения.
-
Универсальность. Добавление всех активов, источников средств и расходов пользователя.
-
Приватность. Как следствие п. № 1 является полностью приватным без хранения данных на сервере. Все данные хранятся на телефоне пользователя.
-
Мультивалютность. Создание любых валют.
-
Мультиаккаунтность. Создание нескольких аккаунтов с различающимися основными валютами.
-
Транзакции. Создание транзакций расходования и перевода средств, пополнения активов и обмена валют.
-
Экран Главная. Основной рабочий экран. Содержит информацию об активах, источниках, транзакциях и иконки: меню, календарь для фильтрования транзакций по датам, обновление статистики и добавление новых данных (аккаунт, валюта, группа, актив/источник и транзакция).
-
Экран Статистика. Содержит данные о доходах и расходах по группам и по каждому источнику в частности. Статистика обновляется в ручном режиме нажатием на иконку обновления статистики на главном экране. При этом появляется предупреждение о необходимости актуализировать стоимость всех активов. Такой подход дает возможность не фиксировать ежедневные расходы, а обновлять их единой суммой по каждому активу, например, раз в квартал. Данные могут быть представлены «Всего» либо как среднее арифметическое «В день», «В месяц», «В год» за указанный промежуток времени. Доход может быть также представлен в виде валовой и чистой (после вычитания расходов) прибыли.
-
Выгрузка и загрузка данных. Поддержка выгрузки и загрузки данных в формате JSON.
-
Совместимость с Excel. Поддержка выгрузки данных в формате.xlsx для хранения и редактирования данных в Excel.
-
Сброс данных. Возможность сброса данных.
-
Защита данных. Возможность установки кода блокировки для защиты от нежелательного входа в приложение.
-
Обучение. Раздел Обучение для быстрого старта, понимания особенностей и функционала приложения. Данный критерий был добавлен уже на этапе тестирования приложения в Google Play, т.к. приложение оказалось сложным для освоения неподготовленным пользователем.
-
Локализация. Поддержка русского и английского языков.
-
Темы. Поддержка светлой и тёмной тем.

Окружение разработки
К моменту начала работы над проектом у меня уже был опыт веб‑разработки и часть необходимого окружения на ОС Windows была мне знакома: редактор кода Visual Studio Code, программная платформа Node.js, система управления версиями Git. Дополнительно были установлены среда разработки Expo (согласно рекомендации с официального сайта RN), Android Studio.
Для отладки приложения я использовал web, Expo Go либо виртуальный девайс Android Studio. Web использовался на начальных этапах разработки, пока не появилась необходимость в имплементации и использовании локального хранения данных. Также я заметил разницу в UI на web и девайсе, что негативно сказывалось на сроках и удобстве работы.
Android Studio — хороший вариант, если у вас производительное «железо» и требуется протестировать приложение на разных версиях Android.
Expo Go создает рабочую версию приложения в браузере девайса при условии подключения к одной локальной сети с компьютером. Когда у меня была такая возможность, я использовал этот вариант как наиболее удобный.
Особенности приложения
Базовая структура приложения была создана с помощью Expo командой create‑expo‑app. Навигация в базовой версии Expo была реализована с помощью React Navigation, что и используется в моем проекте. На момент написания статьи навигация в базовой версии реализуется с помощью библиотеки Expo Router.
Управление состоянием
Для управления состоянием в рамках всего приложения я использовал библиотеку MobX. Для управления состоянием внутри компонента — хук useState. Например, при запуске приложения задается объект с фильтрами и отслеживаемым состоянием filtersStore при помощи функции makeAutoObservable:
import { makeAutoObservable } from "mobx";
import filtersStoreInitial from './filters-store-initial.json'
let filtersStore: IFiltersStore = makeAutoObservable(filtersStoreInitial);
Примером использования хука useState внутри компонента AssetItem могут быть булевые состояния showTransactions, showAssets и showBalance, используемые для отображения и скрытия транзакций, групп активов и источников и баланса:
const [showTransactions, setShowTransactions] = useState(false);
Списки. Группы и транзакции
Группы активов, источников и транзакции реализованы в виде дерева списков FlatList. Каждому элементу родительского списка может соответствовать дочерний список, а каждому элементу дочернего списка свой дочерний список. Таким образом реализована возможность группировки активов и источников не только по изначальным категориям («Карты», «Наличные», «Инвестиции», «Источники»), но и по группам с желаемым уровнем вложенности.
За отображение элементов списков отвечает компонент AssetItem, а за дочерние списки — компонент SubAssets. Получается замкнутая система, когда родительский AssetItem вызывает новый список SubAssets, состоящий из дочерних элементов AssetItem. Каждая ветвь дерева заканчивается списком транзакций актива либо источника (компонент AssetTransfers) при их наличии.
При нажатии на элемент списка AssetItem срабатывает функция обратного вызова handleShowContent. При передаче в нее соответствующих параметров мы отображаем компонент SubAssets, AssetTransfers либо Balance внутри AssetItem.
Сокращенная реализация компонента AssetItem
import { useState } from "react";
import { View, TouchableOpacityMod } from "../Themed";
import { observer } from "mobx-react-lite";
import { AngleDownBtn } from "../AngleDownBtn";
import { AssetTransfers } from "./AssetTransfers";
import { SubAssets } from "./SubAssets";
import { runInAction } from "mobx";
import { Balance } from "./Balance";
import { longPressedAsset } from "../../store/selectStore";
export const AssetItem = observer((assetItemProps: IAssetItem) => {
const [showTransactions, setShowTransactions] = useState(false);
const [showAssets, setShowAssets] = useState(false);
const [showBalance, setShowBalance] = useState(false);
const handleShowContent = (
showContent: boolean,
contentToShow: string
) => {
switch (contentToShow) {
case 'assets':
setShowAssets(showContent);
break;
case 'transactions':
setShowTransactions(showContent);
showContent ? runInAction(() => longPressedAsset.key = null) : false;
break;
case 'balance':
setShowBalance(showContent);
}
showContent
? assetItemProps.handleShowOneParentItem(assetItemProps.item)
: assetItemProps.handleShowOneParentItem(null);
}
let props = {
item: assetItemProps.item,
handleShowContent: handleShowContent
}
return (
<View>
<TouchableOpacityMod
style={[]} // Здесь и дальше стилизация опущена.
>
<View>
<AngleDownBtn {...props} />
{/*
Опущена часть кода, отвечающая за отображение стоимости
и количества.
*/}
</View>
</TouchableOpacityMod>
{ showAssets && <SubAssets item={assetItemProps.item} /> }
{ showTransactions && <AssetTransfers item={assetItemProps.item} /> }
{ showBalance && <Balance /> }
</View>
)
});
Сокращенная реализация компонента SubAssets
import { FlatList, ListRenderItem } from "react-native";
import { userData } from "../UserData";
import { AssetItem } from "./Assets";
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { View } from "../Themed";
export const SubAssets = observer((subAssetsProps: ISubAssetsProps) => {
const [showOneParentItem, setShowOneParentItem] = useState<IAssets | null>(null);
const [assetsData, setAssetsData] = useState<IAssets[]>([]);
const handleShowOneParentItem = (item: IAssets | null) => setShowOneParentItem(item);
useEffect(() => {
if (showOneParentItem && !showOneParentItem.deleted) {
setAssetsData([showOneParentItem]);
} else {
setAssetsData(
userData.assets.filter((el) => {
// Функция, тестирующая элементы массива опущена.
})
);
}
}, [
// Список зависимостей опущен.
]);
const renderItem: ListRenderItem<IAssets> = ({ item }) => (
<AssetItem {...{
item: item,
handleShowOneParentItem: handleShowOneParentItem,
}} />
);
return (
<View>
<FlatList
data={assetsData}
renderItem={renderItem}
keyExtractor={(item) => item.key}
/>
</View>
)
});
Данные компоненты являются универсальными и с некоторыми доработками используются также для отображения деревьев доходов и расходов экрана Статистика.
Создание транзакций
Во время работы приложения данные пользователя хранятся в объекте userData с отслеживаемым состоянием и представляют собой массивы объектов. После создания нового объекта, относящегося к активу, источнику, валюте или аккаунту, часть его значений может быть изменена в дальнейшем (например, название или стоимость), но сам объект полностью удален быть не может. Удаляя актив, пользователь лишь присваивает ключу «deleted» значение «true». В противоположность этому объекты с информацией о транзакциях могут быть удалены полностью при соблюдении определенных условий, но их значения не могут быть скорректированы. Таким образом контролируется корректное распределение стоимости между активами при операциях обмена одной валюты на другую, а также возможный обход ограничений на сумму транзакции в момент ее создания. Таким образом, пользователь может быть уверен в расчетной стоимости активов.
Не все транзакции возможно и нужно учитывать. Поэтому скоро пользователь может обнаружить, что реальная стоимость актива отличается от стоимости в приложении. Не стоит ставить себе задачу скрупулёзной записи всех транзакций — это весьма утомительно. Достаточно фиксировать порядок цифр по памяти, а все расхождения в стоимости актива корректировать расходной транзакцией либо транзакцией пополнения раз в квартал либо другой комфортный промежуток времени. Такой подход позволит оставаться в курсе текущих финансов без лишних подробностей.
Условно все транзакции по типу обработки данных можно разделить на несколько типов:
-
транзакции в валюте аккаунта А, где источник и получатель средств принадлежат аккаунту А;
-
транзакции обмена, где оба субъекта обмена (источник либо получатель) принадлежат аккаунту А и валюта хотя бы одного субъекта не равна валюте аккаунта А;
-
транзакции в валюте аккаунта А либо Б, где источник и получатель средств принадлежат к разным аккаунтам А и Б.
Здесь важно определиться с понятиями, используемыми в данном разделе.
Стоимостью актива будем называть величину затрат на приобретение или изготовление актива, выраженную в валюте аккаунта.
Количеством актива будем называть величину затрат на приобретение или изготовление актива, выраженную в валюте актива.
При этом стоимость актива присутствует у всех активов, а количество только у тех, чья валюта отличается от валюты аккаунта.
Первый тип транзакций самый понятный и простой — мы переносим указанную часть стоимости источника к получателю средств. Если источник средств относится к категории «Источники», то его итоговая стоимость может быть отрицательной, т.к. активы из этой категории нам не принадлежат, и нам не известна и не важна их общая стоимость. Стоимость и количество активов из любой другой категории не могут быть отрицательными.
Расчет стоимости активов при операциях обмена реализована по методу FIFA (ФИФО).
FIFO (ФИФО; акроним англ. First In, First Out — первым пришёл — первым ушёл) — метод оценки ТМЦ, при котором первыми выбывают с учёта ТМЦ поставленные на учёт первыми.
Вам, возможно, будет ближе понятие «очереди» в программировании — принцип тот же. т. е. в первую очередь при расчете стоимости новой транзакции операции обмена мы списываем стоимость входящих транзакций источника средств, поступивших ранее, но еще не списанных. Также был вариант расчета стоимости единицы валюты как среднее арифметическое от текущей, но было решено отказаться от него как от недостаточно наглядного и неиспользуемого в общепринятой практике.
Предположим, вы покупали 100 долларов за 7000 ₽, затем еще 100 долларов за 10 000 ₽ Далее вы решили продать 150 долларов за 13 500 ₽ По методу FIFA стоимость проданных долларов составила 12 000 ₽; 1500 ₽ вы получили в качестве дохода от операции обмена. Тогда как среднеарифметическая стоимость обмена составит 12 750 ₽ и 750 ₽ дохода.

За расчет стоимости новой транзакции отвечает функция getTransactionValue, относящаяся к компоненту CreateTransferModal.
Сокращенная реализация функции getTransactionValue
import { useState } from "react";
import { userData } from "../UserData";
import { observer } from "mobx-react-lite";
import { Modal } from "react-native";
export const CreateTransferModal = observer(((props: ICreateTransfer) => {
// Здесь и далее опущен код, не относящийся к функции getTransactionValue.
const [assetsSelected, setAssetsSelected] = useState<IAssetsSelected>({
source: null,
purpose: null,
});
// Функция, вызываемая при подтверждении создания новой транзакции.
const onSubmit = (transferData: ITransferData) => {
const addNewTransfer = (props: IAddNewTransfer) => {
// Определим константы первоначальной стоимости и количества актива
// (в валюте актива) источника средств.
const sourceInitialValue = userData.assets.find((el) => el.key === assetsSelected.source?.key)!.value;
const sourceInitialQuantity = userData.assets.find((el) => el.key === assetsSelected.source?.key)!.quantity;
// Определим константу количества выбывших активов источника средств
// и константу с ключом валюты аккаунта.
const sourceInitialQuantityOutcomes = userData.transfers
.filter((el) => {
return el.sourceKey === assetsSelected.source?.key
})
.reduce((acc, currentValue) => {
return (currentValue.sourceCurrencyQuantity as number)
+ (acc as number)
}, 0);
const getTransactionValue = () => {
let transactionValue = 0;
// Определим переменную количества либо стоимости
// (для актива в валюте аккаунта) источника средств для обмена,
// указанные пользователем.
let quantityToTransferUpdated = Number(transferData.quantityToTransfer);
if (sourceInitialQuantity === null) {
// Возвращаем указанную пользователем стоимость при условии,
// что валюта источника средств равна валюте аккаута.
return quantityToTransferUpdated;
} else {
let sourceQuantityIncomes = sourceInitialQuantity;
let sourceQuantityOutcomes = sourceInitialQuantityOutcomes;
let currencyValueCalculated = false;
let sourceQuantityIncomesIsUpdated = false;
if (sourceInitialQuantity > sourceInitialQuantityOutcomes) {
if (sourceInitialQuantityOutcomes + quantityToTransferUpdated < sourceInitialQuantity) {
// Возвратим стоимость транзакции посредством пропорции
// для случая, когда количество выбывших активов
// и количество средств для обмена суммарно
// не превышают изначальное количество источника средств.
return quantityToTransferUpdated
* (sourceInitialValue as number)
/ sourceInitialQuantity;
} else {
// Рассчитаем промежуточные стоимость транзакции,
// количество валюты для обмена
// и количество выбывших средств источника в момент,
// когда количество выбывших активов сравнялось
// с первоначальным количеством источника средств.
transactionValue =
(sourceInitialQuantity - sourceInitialQuantityOutcomes)
* (sourceInitialValue as number)
/ sourceInitialQuantity;
quantityToTransferUpdated -=
sourceQuantityIncomes - sourceQuantityOutcomes;
sourceQuantityOutcomes +=
sourceQuantityIncomes - sourceQuantityOutcomes;
}
}
userData.transfers.forEach((el) => {
// Далее дождемся момента,
// когда количество поступивших средств источника
// превысит количество уже выбывших средств.
// Элементы массива, следующие после этого момента,
// будут участвовать в дальнейшем формировании
// стоимости транзакции.
if (
el.purposeKey === assetsSelected.source?.key
&& sourceQuantityOutcomes >= sourceQuantityIncomes
) {
sourceQuantityIncomes += el.purposeCurrencyQuantity !== null
? el.purposeCurrencyQuantity as number
: el.value;
sourceQuantityIncomesIsUpdated = true;
} else sourceQuantityIncomesIsUpdated = false;
// Далее аналогично рассчитываем стоимость транзакции
// для случаев когда:
// 1. количество входящих средств
// превысило количество исходящих
// плюс обновленное количество средств для новой транзакции;
// 2. количество входящих средств
// превысило количество исходящих,
// но их разницы (количество средств "el")
// недостаточно для совершения новой транзакции.
// Для 2-го случая мы заменяем в расчете количество средств
// для новой транзакции на количество средств "el"
// и обновляем переменные.
if (
!currencyValueCalculated
&& el.purposeKey === assetsSelected.source?.key
&& !el.extraValue
&& sourceQuantityOutcomes < sourceQuantityIncomes
) {
if ((sourceQuantityOutcomes + quantityToTransferUpdated) <= sourceQuantityIncomes) {
transactionValue += el.purposeCurrencyQuantity !== null
? quantityToTransferUpdated
* el.value
/ el.purposeCurrencyQuantity
: quantityToTransferUpdated;
currencyValueCalculated = true;
} else {
transactionValue += el.purposeCurrencyQuantity !== null
? (sourceQuantityIncomes - sourceQuantityOutcomes)
* el.value
/ el.purposeCurrencyQuantity
: sourceQuantityIncomes - sourceQuantityOutcomes;
quantityToTransferUpdated -=
sourceQuantityIncomes - sourceQuantityOutcomes;
sourceQuantityOutcomes +=
sourceQuantityIncomes - sourceQuantityOutcomes;
!sourceQuantityIncomesIsUpdated && (
sourceQuantityIncomes +=
el.purposeCurrencyQuantity !== null
? el.purposeCurrencyQuantity
: el.value
)
}
}
})
}
return transactionValue;
}
}
}
return (
<Modal>
{/* Код опущен */}
</Modal>
);
}));
Экран Статистика
Экран статистики состоит из списка дат обновления статистики, выбранного периода, двух фильтров и дерева доходов и расходов.
Деревья реализованы с помощью уже описанных ранее компонентов AssetItem и SubAssets с некоторыми доработками.
Принцип обновления статистики, описанный в разделе «Техническое задание», не позволяет посмотреть статистику, например, за календарный месяц, если пользователь не обновлял ее в последний день каждого месяца. Но возможно выбрать период, включающий в себя этот месяц, и установить фильтр «В среднем за месяц». Это дает пользователю больше гибкости в работе с приложением, не привязывая к конкретным датам, и возможность выбирать, когда и какие операции записывать, когда актуализировать данные по фактической стоимости активов.
Локальное хранение данных
Когда я вышел на этап сборки приложения для его промежуточного тестирования, потребовалось определиться с системой хранения данных, т.к. все состояния возвращались к значениям по умолчанию после перезагрузки приложения. Expo предлагает различные варианты хранения данных. Библиотеки Expo SecureStore и Expo FileSystem удовлетворяют требованию локального хранения данных. Первая библиотека хранит данные в формате ключ‑значение в зашифрованном виде, но имеет ограничение в 2048 байт на значение, что не подходит для хранения большого объема данных пользователя. Поэтому эту библиотеку было решено использовать для хранения кода блокировки, а Expo FileSystem для хранения фильтров и данных пользователя.
Каждый раз при запуске приложения осуществляется проверка, что данные пользователя существуют локально, при помощи функции ensureDirAndFilesExists. С помощью нее же осуществляется создание локальных папки и файла данных.
Сокращенная реализация функции ensureDirAndFilesExists
import userDataInitialEn from '../userDataInitial/user-data-initial-en.json';
import * as FileSystem from 'expo-file-system';
const userDataDir = FileSystem.documentDirectory + 'userData/';
const userDataUri = FileSystem.documentDirectory + 'userData/UserData.json';
let userData: IAssetsDataInitial = userDataInitialEn;
const saveFile = async(fileUri: string, userData: string) => {
await FileSystem.writeAsStringAsync(fileUri, userData);
}
async function ensureDirAndFilesExists() {
// Определим константы с информацией о папке
// и файле с данными пользователя.
const dirInfo = await FileSystem.getInfoAsync(userDataDir);
const userDataInfo = await FileSystem.getInfoAsync(userDataUri);
if (!dirInfo.exists || !userDataInfo.exists) {
// В случает отсутствия папки либо данных пользователя
// создадим их на основе исходных констант.
await FileSystem.makeDirectoryAsync(userDataDir, { intermediates: true });
await saveFile(userDataUri, JSON.stringify(userData));
} else console.log(`Data directory exists`)
}
Сборка и публикация приложения в Google Play
В какой‑то момент я решил, что мое приложение готово к публикации. Но я не предполагал, что вижу лишь верхушку айсберга оставшейся работы. Во‑первых, требовалось создать аккаунт разработчика на Google Play (GP). На этапе заполнения анкеты выяснилось, что для этого требуется оплатить единовременный регистрационный сбор в размере 25 долларов картой, выпущенной за пределами РФ. После чего, внимание, если все пройдет гладко, потребуется провести закрытое тестирование приложения группой не менее 20-и человек в течение 14 дней!
Варианты оплаты сбора начал искать на популярном сайте по продаже личных вещей. Но продавцы виртуальных карт GP разводили руками, узнав про мой запрос. Вариант оплаты в итоге был найден на одном из специализированных форумов для разработчиков, и аккаунт успешно создан.
Но 20 тестировщиков среди знакомых у меня не было. С вопросом также помог Google — нашлось сообщество разработчиков в Telegram, объединенных одной задачей: прохождение закрытого тестирования GP. Но и тут было не все гладко: люди готовы качать твое приложение не из гуманистических соображений, а потому, что им тоже нужно пройти тест. Поэтому приходилось практически ежедневно устанавливать другие апы и немного тестировать их либо тестировать уже установленные. Почему ежедневно, спросите вы, если можно набрать 20 человек и успокоиться на 2-е недели? Как оказалось, не все тестировщики играют честно и попросту удаляют твое приложение по истечении короткого промежутка времени, а значит ничем не помогают. На сколько я понял условия Google, требовалось, чтобы количество тестировщиков не падало ниже 20 в течение 2 недель, а те, что есть, проявляли некоторую активность в твоем приложении для успешного прохождения теста. Это было непросто, т.к. порой в день уходило по 7–8 человек, и вечером, когда у меня было время, приходилось искать новых.
По истечении двух недель я прошел тест с вопросами о результатах закрытого тестирования, и через некоторое время мне пришло долгожданное одобрение с допуском к production. На этапе тестирования я получил обратную связь и стало ясно, что не все понимают, как пользоваться приложением. Поэтому перед запуском в production дополнительно был дописан раздел Обучение.
Итоги
В декабре 2024 г. первая версия приложения была готова. Писал я его по вечерам и в выходные, когда было время. Суммарно на разработку было потрачено порядка 800 часов чистого времени (велся учет), учитывая время на освоение RN и библиотек.
В процессе разработки я изучил новый для себя фреймворк RN, библиотеки, в т.ч. MobX и Expo, научился работе с локальным хранилищем данных, заменившим мне полностью бэкенд, отладке приложения, работе в Google Play Console.
Оглядываясь назад, могу сказать, что, конечно, я не рассчитывал писать приложение два года. На разных этапах разработки я сталкивался со сложностями, на преодоление которых уходило определенное время: ошибки при формировании сборки, неработающие библиотеки, имплементация дерева групп активов и источников и проч. Всё это решалось либо поиском схожих вопросов на Stack Overflow или GitHub, либо методом проб и ошибок.
Это полезный опыт для тех, кто только собирается написать что‑то подобное, для адекватной оценки ваших желаний и возможностей.
В итоге я получил рабочее приложение, которым пользуюсь сам. Ранее никогда не пользовался подобными приложениями ввиду отсутствия:
-
возможности добавления всех своих активов для понимания своего финансового статуса в режиме реального времени, в т.ч. за вычетом неликвидных или не участвующих в повседневных финансовых операциях активов;
-
приватности, т.к. все данные хранятся на серверах и могут попасть к третьим лицам;
-
мультивалютности с возможностью проведения операций обмена с получением финансового результата;
-
мультиаккаунтности для использования нескольких валют в качестве основной;
-
возможности обновления статистики, когда данные в приложении приведены в соответствие с данными реальных активов без привязки к календарю;
-
создания бэкапа для пользования приложением на другом устройстве;
-
совместимости с Excel для возможности обмена данными с уже существующей статистикой либо хранения укрупненных данных;
-
дополнительной защиты в виде пинкода.
Возможно, более опытные пользователи скажут, что пользовались(‑уются) приложением, где все указанные возможности реализованы. С удовольствием его протестирую!
Допускаю, что мое хобби в ближайшем будущем станет частью профессиональной деятельности. В любом случае это был интересный и важный опыт создания чего‑то уникального по лично моей задумке.
Автор: Essonti