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 AndroidKeyboardAvoidingView from './AndroidKeyboardAvoidingView'; import { amountToInteger, integerToAmount } from 'loot-core/src/shared/util'; import * as monthUtils from 'loot-core/src/shared/months'; import memoizeOne from 'memoize-one'; import CellValue from '../spreadsheet/CellValue'; import SheetValue from '../spreadsheet/SheetValue'; import useSheetValue from '../spreadsheet/useSheetValue'; import { colors, mobileStyles as styles } from '../../style'; import format from '../spreadsheet/format'; import { Button, KeyboardButton, Card, Label } from './common'; import { ListItem, ROW_HEIGHT } from './table'; import Platform from 'loot-core/src/client/platform'; import NamespaceContext from '../spreadsheet/NamespaceContext'; import AmountInput, { MathOperations, AmountAccessoryContext } from './AmountInput'; import { DragDrop, Draggable, Droppable, DragDropHighlight } from './dragdrop'; import { rolloverBudget, reportBudget } from 'loot-core/src/client/queries'; import ArrowThinLeft from '../../svg/v1/ArrowThinLeft'; import ArrowThinRight from '../../svg/v1/ArrowThinRight'; import ArrowThinUp from '../../svg/v1/ArrowThinUp'; import ArrowThinDown from '../../svg/v1/ArrowThinDown'; import DotsHorizontalTriple from '../../svg/v1/DotsHorizontalTriple'; import Add from '../../svg/v1/Add'; 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.format(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> ); }