diff --git a/android/app/build.gradle b/android/app/build.gradle index 5623cac..25bd043 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -137,6 +137,7 @@ android { } dependencies { + compile project(':react-native-svg') compile project(':realm') compile fileTree(dir: "libs", include: ["*.jar"]) compile "com.android.support:appcompat-v7:23.0.1" diff --git a/android/app/src/main/java/com/drip/MainApplication.java b/android/app/src/main/java/com/drip/MainApplication.java index 00ce2c0..324dd01 100644 --- a/android/app/src/main/java/com/drip/MainApplication.java +++ b/android/app/src/main/java/com/drip/MainApplication.java @@ -3,6 +3,7 @@ package com.drip; import android.app.Application; import com.facebook.react.ReactApplication; +import com.horcrux.svg.SvgPackage; import io.realm.react.RealmReactPackage; import com.facebook.react.ReactNativeHost; import com.facebook.react.ReactPackage; @@ -24,6 +25,7 @@ public class MainApplication extends Application implements ReactApplication { protected List getPackages() { return Arrays.asList( new MainReactPackage(), + new SvgPackage(), new RealmReactPackage() ); } diff --git a/android/settings.gradle b/android/settings.gradle index 6bba8cd..9b4195f 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -1,4 +1,6 @@ rootProject.name = 'drip' +include ':react-native-svg' +project(':react-native-svg').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-svg/android') include ':realm' project(':realm').projectDir = new File(rootProject.projectDir, '../node_modules/realm/android') diff --git a/app.js b/app.js index 70c1a05..92f7e57 100644 --- a/app.js +++ b/app.js @@ -3,6 +3,7 @@ import Home from './home' import Calendar from './calendar' import CycleDay from './cycle-day' +import Chart from './components/chart' // this is until react native fixes this bug, see https://github.com/facebook/react-native/issues/18868#issuecomment-382671739 import { YellowBox } from 'react-native' @@ -11,5 +12,6 @@ YellowBox.ignoreWarnings(['Warning: isMounted(...) is deprecated']) export default createStackNavigator({ home: { screen: Home }, calendar: { screen: Calendar }, - cycleDay: { screen: CycleDay } + cycleDay: { screen: CycleDay }, + chart: { screen: Chart } }) diff --git a/components/chart.js b/components/chart.js new file mode 100644 index 0000000..b0a6711 --- /dev/null +++ b/components/chart.js @@ -0,0 +1,219 @@ +import React, { Component } from 'react' +import { Text as ReactNativeText, View, FlatList } from 'react-native' +import range from 'date-range' +import Svg,{ + G, + Rect, + Text, + Circle, + Line, + Path +} from 'react-native-svg' +import { LocalDate } from 'js-joda' +import { getCycleDay, getOrCreateCycleDay, cycleDaysSortedByDate } from '../db' +import getCycleDayNumberModule from '../get-cycle-day-number' +import styles from './styles' +import config from './config' + +const getCycleDayNumber = getCycleDayNumberModule() + +const yAxis = makeYAxis(config) + +export default class CycleChart extends Component { + constructor(props) { + super(props) + this.state = { + columns: makeColumnInfo(config.xAxisRangeInDays) + } + + this.reCalculateChartInfo = (function(Chart) { + return function() { + Chart.setState({columns: makeColumnInfo(config.xAxisRangeInDays)}) + } + })(this) + + cycleDaysSortedByDate.addListener(this.reCalculateChartInfo) + } + + componentWillUnmount() { + cycleDaysSortedByDate.removeListener(this.reCalculateChartInfo) + } + + passDateToDayView(dateString) { + const cycleDay = getOrCreateCycleDay(dateString) + this.props.navigation.navigate('cycleDay', { cycleDay }) + } + + placeHorizontalGrid() { + return yAxis.tickPositions.map(tick => { + return ( + + ) + }) + } + + makeDayColumn({ dateString, cycleDay, y }, index) { + const cycleDayNumber = getCycleDayNumber(dateString) + const label = styles.column.label + const dateLabel = dateString.split('-').slice(1).join('-') + + return ( + this.passDateToDayView(dateString)}> + + {this.placeHorizontalGrid()} + {cycleDayNumber} + {dateLabel} + + {cycleDay && cycleDay.bleeding ? + + : null} + + {y ? this.drawDotAndLines(y, cycleDay.temperature.exclude, index) : null} + + ) + } + + drawDotAndLines(currY, exclude, index) { + let lineToRight + let lineToLeft + const cols = this.state.columns + + function makeLine(otherColY, x, excludeLine) { + const middleY = ((otherColY - currY) / 2) + currY + const target = [x, middleY] + const lineStyle = excludeLine ? styles.curveExcluded : styles.curve + + return + } + + const thereIsADotToTheRight = index > 0 && cols[index - 1].y + const thereIsADotToTheLeft = index < cols.length - 1 && cols[index + 1].y + + if (thereIsADotToTheRight) { + const otherDot = cols[index - 1] + const excludedLine = otherDot.cycleDay.temperature.exclude || exclude + lineToRight = makeLine(otherDot.y, config.columnWidth, excludedLine) + } + if (thereIsADotToTheLeft) { + const otherDot = cols[index + 1] + const excludedLine = otherDot.cycleDay.temperature.exclude || exclude + lineToLeft = makeLine(otherDot.y, 0, excludedLine) + } + + const dotStyle = exclude ? styles.curveDotsExcluded : styles.curveDots + return ( + {lineToRight} + {lineToLeft} + + ) + } + + render() { + return ( + + {yAxis.labels} + { + return ( + + {this.makeDayColumn(item, index)} + + ) + }} + keyExtractor={item => item.dateString} + > + + + ) + } +} + +function makeColumnInfo(n) { + const xAxisDates = getPreviousDays(n).map(jsDate => { + return LocalDate.of( + jsDate.getFullYear(), + jsDate.getMonth() + 1, + jsDate.getDate() + ).toString() + }) + + return xAxisDates.map(dateString => { + const cycleDay = getCycleDay(dateString) + const temp = cycleDay && cycleDay.temperature && cycleDay.temperature.value + return { + dateString, + cycleDay, + y: temp ? normalizeToScale(temp) : null + } + }) +} + +function getPreviousDays(n) { + const today = new Date() + today.setHours(0); today.setMinutes(0); today.setSeconds(0); today.setMilliseconds(0) + const earlierDate = new Date(today - (range.DAY * n)) + + return range(earlierDate, today).reverse() +} + +function normalizeToScale(temp) { + const temperatureScale = config.temperatureScale + const valueRelativeToScale = (temperatureScale.high - temp) / (temperatureScale.high - temperatureScale.low) + const scaleHeight = config.chartHeight + return scaleHeight * valueRelativeToScale +} + +function makeYAxis() { + const scaleMin = config.temperatureScale.low + const scaleMax = config.temperatureScale.high + const numberOfTicks = (scaleMax - scaleMin) * 2 + const tickDistance = config.chartHeight / numberOfTicks + + const tickPositions = [] + const labels = [] + + // for style reasons, we don't want the first and last tick + for (let i = 1; i < numberOfTicks - 1; i++) { + const y = tickDistance * i + const style = styles.yAxisLabel + // this eyeballing is sadly necessary because RN does not + // support percentage values for transforms, which we'd need + // to reliably place the label vertically centered to the grid + style.top = y - 8 + labels.push( + + {scaleMax - i * 0.5} + + ) + tickPositions.push(y) + } + + return {labels, tickPositions} +} diff --git a/components/config.js b/components/config.js new file mode 100644 index 0000000..d18c722 --- /dev/null +++ b/components/config.js @@ -0,0 +1,16 @@ +const config = { + chartHeight: 350, + columnWidth: 30, + temperatureScale: { + low: 33, + high: 40 + }, + xAxisRangeInDays: 40 +} + +const margin = 3 +config.columnMiddle = config.columnWidth / 2, +config.dateRowY = config.chartHeight - 15 - margin +config.cycleDayNumberRowY = config.chartHeight - margin + +export default config \ No newline at end of file diff --git a/components/styles.js b/components/styles.js new file mode 100644 index 0000000..9fa2633 --- /dev/null +++ b/components/styles.js @@ -0,0 +1,68 @@ +import config from './config' + +const styles = { + curve: { + stroke: '#ffc425', + strokeWidth: 2 + }, + curveExcluded: { + stroke: 'lightgrey', + strokeWidth: 2, + strokeDashArray: [4] + }, + curveDots: { + fill: '#00aedb', + r: 6 + }, + curveDotsExcluded: { + fill: 'lightgrey', + r: 6 + }, + column: { + label: { + date: { + stroke: 'grey', + fontSize: 10, + x: 2, + fontWeight: '100' + }, + number: { + stroke: '#00b159', + fontSize: 13, + x: config.columnMiddle - 1 + } + }, + rect: { + fill: '#f9f9f9', + strokeWidth: 1, + stroke: 'grey', + x: 0, + y: 0, + width: config.columnWidth, + height: config.chartHeight + } + }, + bleedingIcon: { + fill: '#fb2e01', + scale: 0.6, + x: 7, + y: 3 + }, + yAxis: { + height: config.chartHeight, + width: config.columnWidth, + }, + yAxisLabel: { + position: 'absolute', + right: 3, + color: 'grey', + fontSize: 12, + fontWeight: 'bold' + }, + horizontalGrid: { + stroke: 'lightgrey', + strokeWidth: 1 + } +} + +export default styles \ No newline at end of file diff --git a/db.js b/db.js index a469530..3fdd401 100644 --- a/db.js +++ b/db.js @@ -53,7 +53,7 @@ function saveTemperature(cycleDay, temperature) { }) } -const getCycleDaysSortedByDateView = () => db.objects('CycleDay').sorted('date', true) +const cycleDaysSortedByDate = db.objects('CycleDay').sorted('date', true) function saveBleeding(cycleDay, bleeding) { db.write(() => { @@ -73,6 +73,10 @@ function getOrCreateCycleDay(localDate) { return result } +function getCycleDay(localDate) { + return db.objectForPrimaryKey('CycleDay', localDate) +} + function deleteAll() { db.write(() => { db.deleteAll() @@ -94,7 +98,9 @@ export { saveBleeding, getOrCreateCycleDay, bleedingDaysSortedByDate, - getCycleDaysSortedByDateView, + temperatureDaysSortedByDate, + cycleDaysSortedByDate, deleteAll, - getPreviousTemperature + getPreviousTemperature, + getCycleDay } diff --git a/home.js b/home.js index 0ada69f..48c7948 100644 --- a/home.js +++ b/home.js @@ -65,6 +65,12 @@ export default class Home extends Component { title="Go to calendar"> + + +