5217835c55
* monthUtils.{format → nonLocalizedFormat} * Implement localization for schedule descriptions * Remove outdated comment * Add general.ordinal in Spanish Co-Authored-By: Manuel Eduardo Cánepa Cihuelo <10290593+manuelcanepa@users.noreply.github.com> * yay time zones? * fix: re-add missing keys * fix: fix broken i18n imports/initialisation * style: linting * fix: re-add english ordinal keys * fix: add remaining english ordinal keys * fix: correct dates in schedules.js * refactor: store translations keys for loot-core in loot-core * fix: add ns to i18n.t calls directly so parser can find them * feat: add spanish translation from manuelcanepa * fix: add comments to help i18n-parser to find contexts * fix: add "many" context to spanish translations Co-authored-by: Manuel Eduardo Cánepa Cihuelo <10290593+manuelcanepa@users.noreply.github.com> Co-authored-by: Tom French <tom@tomfren.ch>
1157 lines
31 KiB
JavaScript
1157 lines
31 KiB
JavaScript
import React, { useMemo, useEffect, useContext } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
ScrollView,
|
|
TouchableOpacity,
|
|
NativeModules,
|
|
findNodeHandle,
|
|
Keyboard
|
|
} from 'react-native';
|
|
import {
|
|
RectButton,
|
|
PanGestureHandler,
|
|
NativeViewGestureHandler
|
|
} from 'react-native-gesture-handler';
|
|
import Animated, { Easing } from 'react-native-reanimated';
|
|
|
|
import memoizeOne from 'memoize-one';
|
|
|
|
import Platform from 'loot-core/src/client/platform';
|
|
import { rolloverBudget, reportBudget } from 'loot-core/src/client/queries';
|
|
import * as monthUtils from 'loot-core/src/shared/months';
|
|
import { amountToInteger, integerToAmount } from 'loot-core/src/shared/util';
|
|
|
|
import { colors, mobileStyles as styles } from '../../style';
|
|
import Add from '../../svg/v1/Add';
|
|
import ArrowThinDown from '../../svg/v1/ArrowThinDown';
|
|
import ArrowThinLeft from '../../svg/v1/ArrowThinLeft';
|
|
import ArrowThinRight from '../../svg/v1/ArrowThinRight';
|
|
import ArrowThinUp from '../../svg/v1/ArrowThinUp';
|
|
import DotsHorizontalTriple from '../../svg/v1/DotsHorizontalTriple';
|
|
import CellValue from '../spreadsheet/CellValue';
|
|
import format from '../spreadsheet/format';
|
|
import NamespaceContext from '../spreadsheet/NamespaceContext';
|
|
import SheetValue from '../spreadsheet/SheetValue';
|
|
import useSheetValue from '../spreadsheet/useSheetValue';
|
|
import AmountInput, {
|
|
MathOperations,
|
|
AmountAccessoryContext
|
|
} from './AmountInput';
|
|
import AndroidKeyboardAvoidingView from './AndroidKeyboardAvoidingView';
|
|
import { Button, KeyboardButton, Card, Label } from './common';
|
|
import { DragDrop, Draggable, Droppable, DragDropHighlight } from './dragdrop';
|
|
import { ListItem, ROW_HEIGHT } from './table';
|
|
|
|
const ACTScrollViewManager =
|
|
NativeModules && NativeModules.ACTScrollViewManager;
|
|
|
|
export function ToBudget({ toBudget, onPress }) {
|
|
return (
|
|
<SheetValue binding={toBudget}>
|
|
{({ value: amount }) => {
|
|
return (
|
|
<Button
|
|
bare
|
|
contentStyle={{ flexDirection: 'column', alignItems: 'flex-start' }}
|
|
onPress={onPress}
|
|
>
|
|
<Label
|
|
title={amount < 0 ? 'OVERBUDGETED' : 'TO BUDGET'}
|
|
style={{ color: colors.n1 }}
|
|
/>
|
|
<Text
|
|
style={[
|
|
styles.smallText,
|
|
{
|
|
fontWeight: '500',
|
|
color: amount < 0 ? colors.r4 : colors.n1
|
|
}
|
|
]}
|
|
>
|
|
{format(amount, 'financial')}
|
|
</Text>
|
|
</Button>
|
|
);
|
|
}}
|
|
</SheetValue>
|
|
);
|
|
}
|
|
|
|
function Saved({ projected }) {
|
|
let budgetedSaved = useSheetValue(reportBudget.totalBudgetedSaved) || 0;
|
|
let totalSaved = useSheetValue(reportBudget.totalSaved) || 0;
|
|
let saved = projected ? budgetedSaved : totalSaved;
|
|
let isNegative = saved < 0;
|
|
|
|
return (
|
|
<View style={{ flexDirection: 'column', alignItems: 'flex-start' }}>
|
|
{projected ? (
|
|
<Label title="PROJECTED SAVINGS" style={{ color: colors.n1 }} />
|
|
) : (
|
|
<Label
|
|
title={isNegative ? 'OVERSPENT' : 'SAVED'}
|
|
style={{ color: colors.n1 }}
|
|
/>
|
|
)}
|
|
|
|
<Text
|
|
style={[
|
|
styles.smallText,
|
|
{
|
|
fontWeight: '500',
|
|
color: projected ? colors.y3 : isNegative ? colors.r4 : colors.n1
|
|
}
|
|
]}
|
|
>
|
|
{format(saved, 'financial')}
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
export class BudgetCell extends React.PureComponent {
|
|
render() {
|
|
const {
|
|
name,
|
|
binding,
|
|
editing,
|
|
style,
|
|
textStyle,
|
|
categoryId,
|
|
month,
|
|
onBudgetAction
|
|
} = this.props;
|
|
|
|
return (
|
|
<SheetValue binding={binding}>
|
|
{node => {
|
|
return (
|
|
<View style={style}>
|
|
<AmountInput
|
|
value={integerToAmount(node.value || 0)}
|
|
inputAccessoryViewID="budget"
|
|
style={[
|
|
{
|
|
height: ROW_HEIGHT - 4,
|
|
transform: [{ translateX: 6 }]
|
|
},
|
|
!editing && { opacity: 0, position: 'absolute', top: 0 }
|
|
]}
|
|
focused={editing}
|
|
scrollIntoView={Platform.OS === 'android'}
|
|
textStyle={[styles.smallText, textStyle]}
|
|
animationColor="white"
|
|
onBlur={value => {
|
|
onBudgetAction(month, 'budget-amount', {
|
|
category: categoryId,
|
|
amount: amountToInteger(value)
|
|
});
|
|
}}
|
|
/>
|
|
|
|
<View
|
|
style={[
|
|
{
|
|
justifyContent: 'center',
|
|
height: ROW_HEIGHT - 4
|
|
},
|
|
editing && { display: 'none' }
|
|
]}
|
|
>
|
|
<Text style={[styles.smallText, textStyle]} data-testid={name}>
|
|
{format(node.value || 0, 'financial')}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
);
|
|
}}
|
|
</SheetValue>
|
|
);
|
|
}
|
|
}
|
|
|
|
function BudgetGroupPreview({ group, pending, style }) {
|
|
let opacity = useMemo(() => new Animated.Value(0), []);
|
|
|
|
useEffect(() => {
|
|
Animated.timing(opacity, {
|
|
toValue: 1,
|
|
duration: 100,
|
|
easing: Easing.inOut(Easing.ease)
|
|
}).start();
|
|
}, []);
|
|
|
|
return (
|
|
<Animated.View
|
|
style={[
|
|
style,
|
|
{ opacity },
|
|
pending && {
|
|
shadowColor: '#000',
|
|
shadowOffset: {
|
|
width: 0,
|
|
height: 3
|
|
},
|
|
shadowOpacity: 0.45,
|
|
shadowRadius: 20,
|
|
elevation: 5
|
|
}
|
|
]}
|
|
>
|
|
<Card
|
|
style={{
|
|
marginTop: 7,
|
|
marginBottom: 7,
|
|
opacity: pending ? 1 : 0.4
|
|
}}
|
|
>
|
|
<TotalsRow group={group} blank={true} />
|
|
|
|
{group.categories.map((cat, index) => (
|
|
<BudgetCategory category={cat} blank={true} index={index} />
|
|
))}
|
|
</Card>
|
|
</Animated.View>
|
|
);
|
|
}
|
|
|
|
function BudgetCategoryPreview({ name, pending, style }) {
|
|
return (
|
|
<Animated.View
|
|
style={[
|
|
style,
|
|
{ opacity: pending ? 1 : 0.4 },
|
|
{
|
|
backgroundColor: 'white',
|
|
shadowColor: '#000',
|
|
shadowOffset: {
|
|
width: 0,
|
|
height: 2
|
|
},
|
|
shadowOpacity: 0.25,
|
|
shadowRadius: 10,
|
|
elevation: 5
|
|
}
|
|
]}
|
|
>
|
|
<ListItem
|
|
style={{
|
|
flex: 1,
|
|
borderColor: 'transparent',
|
|
borderRadius: 4
|
|
}}
|
|
>
|
|
<Text style={styles.smallText}>{name}</Text>
|
|
</ListItem>
|
|
</Animated.View>
|
|
);
|
|
}
|
|
|
|
export class BudgetCategory extends React.PureComponent {
|
|
constructor(props) {
|
|
super(props);
|
|
|
|
let { editMode, blank } = props;
|
|
this.opacity = new Animated.Value(editMode || blank ? 0 : 1);
|
|
}
|
|
|
|
componentDidUpdate(prevProps) {
|
|
if (prevProps.editing !== this.props.editing) {
|
|
if (this.props.editing && ACTScrollViewManager) {
|
|
ACTScrollViewManager.setFocused(findNodeHandle(this.container));
|
|
}
|
|
}
|
|
|
|
if (prevProps.editMode !== this.props.editMode) {
|
|
Animated.timing(this.opacity, {
|
|
toValue: this.props.editMode ? 0 : 1,
|
|
duration: 200,
|
|
easing: Easing.inOut(Easing.ease)
|
|
}).start();
|
|
}
|
|
}
|
|
|
|
render() {
|
|
let {
|
|
category,
|
|
editing,
|
|
index,
|
|
gestures,
|
|
editMode,
|
|
style,
|
|
month,
|
|
onEdit,
|
|
onBudgetAction
|
|
} = this.props;
|
|
|
|
let budgeted = rolloverBudget.catBudgeted(category.id);
|
|
let balance = rolloverBudget.catBalance(category.id);
|
|
|
|
let content = (
|
|
<ListItem
|
|
ref={el => (this.container = el)}
|
|
style={[
|
|
{
|
|
backgroundColor: editing ? colors.p11 : 'transparent',
|
|
borderBottomWidth: 0,
|
|
borderTopWidth: index > 0 ? 1 : 0
|
|
},
|
|
style
|
|
]}
|
|
data-testid="row"
|
|
>
|
|
<View style={{ flex: 1 }}>
|
|
<Text style={styles.smallText}>{category.name}</Text>
|
|
</View>
|
|
<Animated.View
|
|
style={{
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
opacity: this.opacity
|
|
}}
|
|
>
|
|
<BudgetCell
|
|
name="budgeted"
|
|
binding={budgeted}
|
|
editing={editing}
|
|
style={{ width: 90 }}
|
|
textStyle={[styles.smallText, { textAlign: 'right' }]}
|
|
categoryId={category.id}
|
|
month={month}
|
|
onBudgetAction={onBudgetAction}
|
|
/>
|
|
<CellValue
|
|
name="balance"
|
|
binding={balance}
|
|
style={[styles.smallText, { width: 90, textAlign: 'right' }]}
|
|
getStyle={value => value < 0 && { color: colors.r4 }}
|
|
type="financial"
|
|
/>
|
|
</Animated.View>
|
|
</ListItem>
|
|
);
|
|
|
|
if (!editMode) {
|
|
return (
|
|
<TouchableOpacity
|
|
onPress={() => onEdit(category.id)}
|
|
activeOpacity={0.7}
|
|
>
|
|
{content}
|
|
</TouchableOpacity>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Draggable
|
|
id={category.id}
|
|
type="category"
|
|
preview={({ pending, style }) => (
|
|
<BudgetCategoryPreview
|
|
name={category.name}
|
|
pending={pending}
|
|
style={style}
|
|
/>
|
|
)}
|
|
gestures={gestures}
|
|
>
|
|
<Droppable
|
|
type="category"
|
|
getActiveStatus={(x, y, { layout }, { id }) => {
|
|
let pos = (y - layout.y) / layout.height;
|
|
return pos < 0.5 ? 'top' : 'bottom';
|
|
}}
|
|
onDrop={(id, type, droppable, status) =>
|
|
this.props.onReorder(id.replace('category:', ''), {
|
|
aroundCategory: {
|
|
id: category.id,
|
|
position: status
|
|
}
|
|
})
|
|
}
|
|
>
|
|
{() => content}
|
|
</Droppable>
|
|
</Draggable>
|
|
);
|
|
}
|
|
}
|
|
|
|
export class TotalsRow extends React.PureComponent {
|
|
constructor(props) {
|
|
super(props);
|
|
|
|
let { editMode, blank } = props;
|
|
this.animation = new Animated.Value(editMode || blank ? 0 : 1);
|
|
}
|
|
|
|
componentDidUpdate(prevProps) {
|
|
if (prevProps.editMode !== this.props.editMode) {
|
|
Animated.timing(this.animation, {
|
|
toValue: this.props.editMode ? 0 : 1,
|
|
duration: 200,
|
|
easing: Easing.inOut(Easing.ease)
|
|
}).start();
|
|
}
|
|
}
|
|
|
|
render() {
|
|
let { group, editMode, onAddCategory } = this.props;
|
|
|
|
let content = (
|
|
<ListItem
|
|
style={{
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
backgroundColor: colors.n11
|
|
}}
|
|
data-testid="totals"
|
|
>
|
|
<View style={{ flex: 1 }}>
|
|
<Text
|
|
style={[styles.smallText, { fontWeight: '500' }]}
|
|
data-testid="name"
|
|
>
|
|
{group.name}
|
|
</Text>
|
|
</View>
|
|
<Animated.View
|
|
style={{
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
opacity: this.animation
|
|
}}
|
|
>
|
|
<CellValue
|
|
binding={rolloverBudget.groupBudgeted(group.id)}
|
|
style={[
|
|
styles.smallText,
|
|
{ width: 90, fontWeight: '500', textAlign: 'right' }
|
|
]}
|
|
type="financial"
|
|
/>
|
|
<CellValue
|
|
binding={rolloverBudget.groupBalance(group.id)}
|
|
style={[
|
|
styles.smallText,
|
|
{ width: 90, fontWeight: '500', textAlign: 'right' }
|
|
]}
|
|
type="financial"
|
|
/>
|
|
</Animated.View>
|
|
|
|
{editMode && (
|
|
<Animated.View
|
|
style={{
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
opacity: this.opacity,
|
|
position: 'absolute',
|
|
top: 0,
|
|
bottom: 0,
|
|
right: this.animation.interpolate({
|
|
inputRange: [0, 1],
|
|
outputRange: [5, -30]
|
|
})
|
|
}}
|
|
>
|
|
<RectButton
|
|
onPress={() => onAddCategory(group.id)}
|
|
style={{ padding: 10 }}
|
|
>
|
|
<Add width={15} height={15} color={colors.n1} />
|
|
</RectButton>
|
|
</Animated.View>
|
|
)}
|
|
</ListItem>
|
|
);
|
|
|
|
if (!editMode) {
|
|
return content;
|
|
}
|
|
|
|
return (
|
|
<Droppable
|
|
type="category"
|
|
getActiveStatus={(x, y, { layout }, { id }) => {
|
|
return 'bottom';
|
|
}}
|
|
onDrop={(id, type, droppable, status) =>
|
|
this.props.onReorderCategory(id, { inGroup: group.id })
|
|
}
|
|
>
|
|
{() => content}
|
|
</Droppable>
|
|
);
|
|
}
|
|
}
|
|
|
|
export class IncomeCategory extends React.PureComponent {
|
|
render() {
|
|
const {
|
|
name,
|
|
budget,
|
|
balance,
|
|
style,
|
|
nameTextStyle,
|
|
amountTextStyle
|
|
} = this.props;
|
|
return (
|
|
<ListItem
|
|
style={[
|
|
{
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
padding: 10,
|
|
backgroundColor: 'transparent'
|
|
},
|
|
style
|
|
]}
|
|
>
|
|
<View style={{ flex: 1 }}>
|
|
<Text style={[styles.smallText, nameTextStyle]} data-testid="name">
|
|
{name}
|
|
</Text>
|
|
</View>
|
|
{budget && (
|
|
<CellValue
|
|
binding={budget}
|
|
style={[
|
|
styles.smallText,
|
|
{ width: 90, textAlign: 'right' },
|
|
amountTextStyle
|
|
]}
|
|
type="financial"
|
|
/>
|
|
)}
|
|
<CellValue
|
|
binding={balance}
|
|
style={[
|
|
styles.smallText,
|
|
{ width: 90, textAlign: 'right' },
|
|
amountTextStyle
|
|
]}
|
|
type="financial"
|
|
/>
|
|
</ListItem>
|
|
);
|
|
}
|
|
}
|
|
|
|
export function BudgetAccessoryView() {
|
|
let emitter = useContext(AmountAccessoryContext);
|
|
|
|
return (
|
|
<View>
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
justifyContent: 'flex-end',
|
|
alignItems: 'stretch',
|
|
backgroundColor: colors.n10,
|
|
padding: 5,
|
|
height: 45
|
|
}}
|
|
>
|
|
<MathOperations emitter={emitter} />
|
|
<View style={{ flex: 1 }} />
|
|
<KeyboardButton
|
|
onPress={() => emitter.emit('moveUp')}
|
|
style={{ marginRight: 5 }}
|
|
data-testid="up"
|
|
>
|
|
<ArrowThinUp width={13} height={13} />
|
|
</KeyboardButton>
|
|
<KeyboardButton
|
|
onPress={() => emitter.emit('moveDown')}
|
|
style={{ marginRight: 5 }}
|
|
data-testid="down"
|
|
>
|
|
<ArrowThinDown width={13} height={13} />
|
|
</KeyboardButton>
|
|
<KeyboardButton onPress={() => emitter.emit('done')} data-testid="done">
|
|
Done
|
|
</KeyboardButton>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
export class BudgetGroup extends React.PureComponent {
|
|
render() {
|
|
const {
|
|
group,
|
|
editingId,
|
|
editMode,
|
|
gestures,
|
|
month,
|
|
onEditCategory,
|
|
onReorderCategory,
|
|
onReorderGroup,
|
|
onAddCategory,
|
|
onBudgetAction
|
|
} = this.props;
|
|
|
|
function editable(content) {
|
|
if (!editMode) {
|
|
return content;
|
|
}
|
|
|
|
return (
|
|
<Draggable
|
|
id={group.id}
|
|
type="group"
|
|
preview={({ pending, style }) => (
|
|
<BudgetGroupPreview group={group} pending={pending} style={style} />
|
|
)}
|
|
gestures={gestures}
|
|
>
|
|
<Droppable
|
|
type="group"
|
|
getActiveStatus={(x, y, { layout }, { id }) => {
|
|
let pos = (y - layout.y) / layout.height;
|
|
return pos < 0.5 ? 'top' : 'bottom';
|
|
}}
|
|
onDrop={(id, type, droppable, status) => {
|
|
onReorderGroup(id, group.id, status);
|
|
}}
|
|
>
|
|
{() => content}
|
|
</Droppable>
|
|
</Draggable>
|
|
);
|
|
}
|
|
|
|
return editable(
|
|
<Card
|
|
style={{
|
|
marginTop: 7,
|
|
marginBottom: 7
|
|
}}
|
|
>
|
|
<TotalsRow
|
|
group={group}
|
|
budgeted={rolloverBudget.groupBudgeted(group.id)}
|
|
balance={rolloverBudget.groupBalance(group.id)}
|
|
editMode={editMode}
|
|
onAddCategory={onAddCategory}
|
|
onReorderCategory={onReorderCategory}
|
|
/>
|
|
|
|
{group.categories.map((category, index) => {
|
|
const editing = editingId === category.id;
|
|
return (
|
|
<BudgetCategory
|
|
key={category.id}
|
|
index={index}
|
|
category={category}
|
|
editing={editing}
|
|
editMode={editMode}
|
|
gestures={gestures}
|
|
month={month}
|
|
onEdit={onEditCategory}
|
|
onReorder={onReorderCategory}
|
|
onBudgetAction={onBudgetAction}
|
|
/>
|
|
);
|
|
})}
|
|
</Card>
|
|
);
|
|
}
|
|
}
|
|
|
|
export class IncomeBudgetGroup extends React.Component {
|
|
render() {
|
|
const { type, group } = this.props;
|
|
return (
|
|
<View>
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'flex-end',
|
|
marginTop: 50,
|
|
marginBottom: 5,
|
|
marginRight: 14
|
|
}}
|
|
>
|
|
{type === 'report' && (
|
|
<Label title="BUDGETED" style={{ width: 90 }} />
|
|
)}
|
|
<Label title="RECEIVED" style={{ width: 90 }} />
|
|
</View>
|
|
|
|
<Card style={{ marginTop: 0 }}>
|
|
<IncomeCategory
|
|
name="Income"
|
|
budget={
|
|
type === 'report' ? reportBudget.groupBudgeted(group.id) : null
|
|
}
|
|
balance={
|
|
type === 'report'
|
|
? reportBudget.groupSumAmount(group.id)
|
|
: rolloverBudget.groupSumAmount(group.id)
|
|
}
|
|
nameTextStyle={{ fontWeight: '500' }}
|
|
amountTextStyle={{ fontWeight: '500' }}
|
|
style={{ backgroundColor: colors.n11 }}
|
|
/>
|
|
|
|
{group.categories.map((category, index) => {
|
|
return (
|
|
<IncomeCategory
|
|
key={category.id}
|
|
type={type}
|
|
name={category.name}
|
|
budget={
|
|
type === 'report'
|
|
? reportBudget.catBudgeted(category.id)
|
|
: null
|
|
}
|
|
balance={
|
|
type === 'report'
|
|
? reportBudget.catSumAmount(category.id)
|
|
: rolloverBudget.catSumAmount(category.id)
|
|
}
|
|
index={index}
|
|
/>
|
|
);
|
|
})}
|
|
</Card>
|
|
</View>
|
|
);
|
|
}
|
|
}
|
|
|
|
export class BudgetGroups extends React.Component {
|
|
getGroups = memoizeOne(groups => {
|
|
return {
|
|
incomeGroup: groups.find(group => group.is_income),
|
|
expenseGroups: groups.filter(group => !group.is_income)
|
|
};
|
|
});
|
|
|
|
render() {
|
|
const {
|
|
type,
|
|
categoryGroups,
|
|
editingId,
|
|
editMode,
|
|
gestures,
|
|
month,
|
|
onEditCategory,
|
|
onAddCategory,
|
|
onReorderCategory,
|
|
onReorderGroup,
|
|
onBudgetAction
|
|
} = this.props;
|
|
const { incomeGroup, expenseGroups } = this.getGroups(categoryGroups);
|
|
|
|
return (
|
|
<View style={{ marginBottom: 15 }}>
|
|
{expenseGroups.map(group => {
|
|
return (
|
|
<BudgetGroup
|
|
key={group.id}
|
|
group={group}
|
|
editingId={editingId}
|
|
editMode={editMode}
|
|
gestures={gestures}
|
|
month={month}
|
|
onEditCategory={onEditCategory}
|
|
onAddCategory={onAddCategory}
|
|
onReorderCategory={onReorderCategory}
|
|
onReorderGroup={onReorderGroup}
|
|
onBudgetAction={onBudgetAction}
|
|
/>
|
|
);
|
|
})}
|
|
|
|
{incomeGroup && <IncomeBudgetGroup type={type} group={incomeGroup} />}
|
|
</View>
|
|
);
|
|
}
|
|
}
|
|
|
|
export class BudgetTable extends React.Component {
|
|
static contextType = AmountAccessoryContext;
|
|
state = { editingCategory: null };
|
|
|
|
constructor(props) {
|
|
super(props);
|
|
this.gestures = {
|
|
scroll: React.createRef(null),
|
|
pan: React.createRef(null),
|
|
rows: []
|
|
};
|
|
}
|
|
|
|
componentDidMount() {
|
|
if (ACTScrollViewManager) {
|
|
ACTScrollViewManager.activate(
|
|
(this.list.getNode
|
|
? this.list.getNode()
|
|
: this.list
|
|
).getScrollableNode()
|
|
);
|
|
}
|
|
|
|
const removeFocus = this.props.navigation.addListener('focus', () => {
|
|
if (ACTScrollViewManager) {
|
|
ACTScrollViewManager.activate(
|
|
(this.list.getNode
|
|
? this.list.getNode()
|
|
: this.list
|
|
).getScrollableNode()
|
|
);
|
|
}
|
|
});
|
|
|
|
const keyboardWillHide = e => {
|
|
if (ACTScrollViewManager) {
|
|
ACTScrollViewManager.setFocused(-1);
|
|
}
|
|
this.onEditCategory(null);
|
|
};
|
|
|
|
let keyListener = Keyboard.addListener(
|
|
Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide',
|
|
keyboardWillHide
|
|
);
|
|
|
|
let emitter = this.context;
|
|
emitter.on('done', this.onKeyboardDone);
|
|
emitter.on('moveUp', this.onMoveUp);
|
|
emitter.on('moveDown', this.onMoveDown);
|
|
|
|
this.cleanup = () => {
|
|
removeFocus();
|
|
keyListener.remove();
|
|
|
|
emitter.off('done', this.onKeyboardDone);
|
|
emitter.off('moveUp', this.onMoveUp);
|
|
emitter.off('moveDown', this.onMoveDown);
|
|
};
|
|
}
|
|
|
|
componentWillUnmount() {
|
|
this.cleanup();
|
|
}
|
|
|
|
onEditCategory = id => {
|
|
this.setState({ editingCategory: id });
|
|
};
|
|
|
|
onKeyboardDone = () => {
|
|
Keyboard.dismiss();
|
|
|
|
if (Platform.isReactNativeWeb) {
|
|
// TODO: If we are running tests, they can't rely on the
|
|
// keyboard events, so manually reset the state here. Hopefully
|
|
// we can find a better solution for this in the future.
|
|
this.onEditCategory(null);
|
|
}
|
|
};
|
|
|
|
onMoveUp = () => {
|
|
const { categories } = this.props;
|
|
const { editingCategory } = this.state;
|
|
const expenseCategories = categories.filter(cat => !cat.is_income);
|
|
|
|
const idx = expenseCategories.findIndex(cat => editingCategory === cat.id);
|
|
if (idx - 1 >= 0) {
|
|
this.onEditCategory(expenseCategories[idx - 1].id);
|
|
}
|
|
};
|
|
|
|
onMoveDown = () => {
|
|
const { categories } = this.props;
|
|
const { editingCategory } = this.state;
|
|
const expenseCategories = categories.filter(cat => !cat.is_income);
|
|
|
|
const idx = expenseCategories.findIndex(cat => editingCategory === cat.id);
|
|
if (idx + 1 < expenseCategories.length) {
|
|
this.onEditCategory(expenseCategories[idx + 1].id);
|
|
}
|
|
};
|
|
|
|
render() {
|
|
const {
|
|
type,
|
|
categoryGroups,
|
|
month,
|
|
monthBounds,
|
|
editMode,
|
|
refreshControl,
|
|
onPrevMonth,
|
|
onNextMonth,
|
|
onAddCategory,
|
|
onReorderCategory,
|
|
onReorderGroup,
|
|
onShowBudgetDetails,
|
|
onOpenActionSheet,
|
|
onBudgetAction
|
|
} = this.props;
|
|
let { editingCategory } = this.state;
|
|
let currentMonth = monthUtils.currentMonth();
|
|
|
|
return (
|
|
<NamespaceContext.Provider value={monthUtils.sheetForMonth(month, type)}>
|
|
<View
|
|
style={{ flex: 1, backgroundColor: 'white' }}
|
|
data-testid="budget-table"
|
|
>
|
|
<BudgetHeader
|
|
currentMonth={month}
|
|
monthBounds={monthBounds}
|
|
editMode={editMode}
|
|
onDone={() => this.props.onEditMode(false)}
|
|
onOpenActionSheet={onOpenActionSheet}
|
|
onPrevMonth={onPrevMonth}
|
|
onNextMonth={onNextMonth}
|
|
/>
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
paddingHorizontal: 10,
|
|
paddingVertical: 10,
|
|
paddingRight: 14,
|
|
backgroundColor: 'white',
|
|
borderBottomWidth: 1,
|
|
borderColor: colors.n9
|
|
}}
|
|
>
|
|
{type === 'report' ? (
|
|
<Saved projected={month >= currentMonth} />
|
|
) : (
|
|
<ToBudget
|
|
toBudget={rolloverBudget.toBudget}
|
|
onPress={onShowBudgetDetails}
|
|
/>
|
|
)}
|
|
<View style={{ flex: 1, zIndex: -1 }} />
|
|
|
|
<View style={{ width: 90 }}>
|
|
<Label title="BUDGETED" style={{ color: colors.n1 }} />
|
|
<CellValue
|
|
binding={reportBudget.totalBudgetedExpense}
|
|
type="financial"
|
|
style={[
|
|
styles.smallText,
|
|
{ color: colors.n1, textAlign: 'right', fontWeight: '500' }
|
|
]}
|
|
formatter={value => {
|
|
return format(-parseFloat(value || '0'), 'financial');
|
|
}}
|
|
/>
|
|
</View>
|
|
<View style={{ width: 90 }}>
|
|
<Label title="BALANCE" style={{ color: colors.n1 }} />
|
|
<CellValue
|
|
binding={rolloverBudget.totalBalance}
|
|
type="financial"
|
|
style={[
|
|
styles.smallText,
|
|
{ color: colors.n1, textAlign: 'right', fontWeight: '500' }
|
|
]}
|
|
/>
|
|
</View>
|
|
</View>
|
|
|
|
<AndroidKeyboardAvoidingView includeStatusBar={true}>
|
|
{!editMode ? (
|
|
<ScrollView
|
|
ref={el => (this.list = el)}
|
|
keyboardShouldPersistTaps="always"
|
|
refreshControl={refreshControl}
|
|
style={{ backgroundColor: colors.n10 }}
|
|
automaticallyAdjustContentInsets={false}
|
|
>
|
|
<BudgetGroups
|
|
type={type}
|
|
categoryGroups={categoryGroups}
|
|
editingId={editingCategory}
|
|
editMode={editMode}
|
|
gestures={this.gestures}
|
|
month={month}
|
|
onEditCategory={this.onEditCategory}
|
|
onAddCategory={onAddCategory}
|
|
onReorderCategory={onReorderCategory}
|
|
onReorderGroup={onReorderGroup}
|
|
onBudgetAction={onBudgetAction}
|
|
/>
|
|
</ScrollView>
|
|
) : (
|
|
<DragDrop>
|
|
{({
|
|
dragging,
|
|
onGestureEvent,
|
|
onHandlerStateChange,
|
|
scrollRef,
|
|
onScroll
|
|
}) => (
|
|
<NativeViewGestureHandler
|
|
enabled={!dragging}
|
|
waitFor={this.gestures.pan}
|
|
ref={this.gestures.scroll}
|
|
>
|
|
<Animated.ScrollView
|
|
ref={el => {
|
|
scrollRef.current = el;
|
|
this.list = el;
|
|
}}
|
|
onScroll={onScroll}
|
|
keyboardShouldPersistTaps="always"
|
|
scrollEventThrottle={16}
|
|
scrollEnabled={!dragging}
|
|
style={{ backgroundColor: colors.n10 }}
|
|
>
|
|
<PanGestureHandler
|
|
avgTouches
|
|
minDeltaX={2}
|
|
minDeltaY={2}
|
|
maxPointers={1}
|
|
onGestureEvent={onGestureEvent}
|
|
onHandlerStateChange={onHandlerStateChange}
|
|
ref={this.gestures.pan}
|
|
waitFor={this.gestures.scroll}
|
|
>
|
|
<Animated.View>
|
|
<BudgetGroups
|
|
categoryGroups={categoryGroups}
|
|
editingId={editingCategory}
|
|
editMode={editMode}
|
|
gestures={this.gestures}
|
|
onEditCategory={this.onEditCategory}
|
|
onAddCategory={onAddCategory}
|
|
onReorderCategory={onReorderCategory}
|
|
onReorderGroup={onReorderGroup}
|
|
/>
|
|
</Animated.View>
|
|
</PanGestureHandler>
|
|
|
|
<DragDropHighlight />
|
|
</Animated.ScrollView>
|
|
</NativeViewGestureHandler>
|
|
)}
|
|
</DragDrop>
|
|
)}
|
|
</AndroidKeyboardAvoidingView>
|
|
</View>
|
|
</NamespaceContext.Provider>
|
|
);
|
|
}
|
|
}
|
|
|
|
export function BudgetHeader({
|
|
currentMonth,
|
|
monthBounds,
|
|
editMode,
|
|
onDone,
|
|
onOpenActionSheet,
|
|
onPrevMonth,
|
|
onNextMonth
|
|
}) {
|
|
let prevEnabled = currentMonth > monthBounds.start;
|
|
let nextEnabled = currentMonth < monthUtils.subMonths(monthBounds.end, 1);
|
|
|
|
let buttonStyle = {
|
|
paddingHorizontal: 15,
|
|
backgroundColor: 'transparent'
|
|
};
|
|
|
|
return (
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
justifyContent: 'center',
|
|
alignItems: 'stretch',
|
|
backgroundColor: colors.p5
|
|
}}
|
|
>
|
|
{!editMode && (
|
|
<Button
|
|
bare
|
|
hitSlop={{ top: 5, bottom: 5, left: 0, right: 30 }}
|
|
onPress={prevEnabled && onPrevMonth}
|
|
style={[buttonStyle, { left: 0, opacity: prevEnabled ? 1 : 0.6 }]}
|
|
>
|
|
<ArrowThinLeft style={{ color: colors.n11 }} width="15" height="15" />
|
|
</Button>
|
|
)}
|
|
<Text
|
|
style={[
|
|
styles.smallText,
|
|
{
|
|
marginVertical: 12,
|
|
color: colors.n11,
|
|
textAlign: 'center',
|
|
fontWeight: '600',
|
|
zIndex: -1
|
|
}
|
|
]}
|
|
>
|
|
{monthUtils.nonLocalizedFormat(currentMonth, "MMMM ''yy")}
|
|
</Text>
|
|
{editMode ? (
|
|
<Button
|
|
bare
|
|
onPress={onDone}
|
|
style={[
|
|
buttonStyle,
|
|
{ position: 'absolute', top: 0, bottom: 0, right: 0 }
|
|
]}
|
|
textStyle={{
|
|
color: colors.n11,
|
|
fontSize: 15,
|
|
fontWeight: '500'
|
|
}}
|
|
>
|
|
Done
|
|
</Button>
|
|
) : (
|
|
<>
|
|
<Button
|
|
bare
|
|
onPress={nextEnabled && onNextMonth}
|
|
hitSlop={{ top: 5, bottom: 5, left: 30, right: 5 }}
|
|
style={[buttonStyle, { opacity: nextEnabled ? 1 : 0.6 }]}
|
|
>
|
|
<ArrowThinRight
|
|
style={{ color: colors.n11 }}
|
|
width="15"
|
|
height="15"
|
|
/>
|
|
</Button>
|
|
|
|
<Button
|
|
bare
|
|
onPress={onOpenActionSheet}
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
bottom: 0,
|
|
right: 0,
|
|
backgroundColor: 'transparent',
|
|
paddingHorizontal: 12
|
|
}}
|
|
hitSlop={{
|
|
top: 5,
|
|
bottom: 5,
|
|
left: 20,
|
|
right: 20
|
|
}}
|
|
>
|
|
<DotsHorizontalTriple
|
|
width="20"
|
|
height="20"
|
|
style={{ color: 'white' }}
|
|
/>
|
|
</Button>
|
|
</>
|
|
)}
|
|
</View>
|
|
);
|
|
}
|