DbTradeAlert for Android: Finish the Watchlist UI

Update 2016-11-26: Replaced last price in charts with a vertical line mostly to avoid it overdrawing the win / loss value. This isn’t shown here and in following posts as it affects a lot of screenshots.


First post in this series: Introduction to DbTradeAlert

Previous post: Add a Database


Finishing the watchlist UI means:

  • add the missing TextViews to fragment_report.xml
  • add fields for those TextViews to WatchlistRecyclerViewAdapter.ViewHolder and connect them to data in WatchlistRecyclerViewAdapter
  • add color for example to warn about old data
  • add a chart to each report

1. Add the Missing TextViews to fragment_report.xml

Currently only symbol and last price are shown for each report and fragment_report.xml needs to be prepared for the other values like this:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content" xmlns:tools="http://schemas.android.com/tools">

    <TextView android:id="@+id/securityNameTextView" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentLeft="true" android:layout_alignParentStart="true" android:textAppearance="?android:attr/textAppearanceLarge" tools:text="NESTLE N" >
    </TextView>

    <TextView android:id="@+id/percentChangeTextView" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentEnd="true" android:layout_alignParentRight="true" android:textAppearance="?android:attr/textAppearanceLarge" tools:text="0.41 %" >
    </TextView>

    <TextView android:id="@+id/lastPriceDateTimeTextView" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentEnd="true" android:layout_alignParentRight="true" android:layout_below="@id/percentChangeTextView" android:textAppearance="?android:attr/textAppearanceMedium" tools:text="10:24, 12.05.2016" >
    </TextView>

    <TextView android:id="@+id/symbolTextView" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentLeft="true" android:layout_alignParentStart="true" android:layout_below="@+id/securityNameTextView" android:textAppearance="?android:attr/textAppearanceMedium" tools:text="NESN.VX" />
<!-- ... -->

A LinearLayout can only present its views as a list and RelativeLayout had to take over. The 2 TextViews got 6 siblings and most TextViews are aligned to the right or left of the parent view. The respective attributes come as twins like “layout_alignParentLeft” and “layout_alignParentStart” to support right-to-left layouts and not having them would lead to warnings, too. Other layout attributes like “layout_below” and “layout_toEndOf” / “layout_toRightOf” position views relative to other views – RelativeLayout has its name for a reason.

This layout contains hardcoded sample data to get a better idea of what it will look like. To avoid warnings by Android Studio this text is provided in “tools:text” attributes and not in the usual “android:text” atributes. Note that declaring the respective XML namespace “xmlns:tools=…” is required.

Looking for text size, color, or font specifications in vain? Android uses a more maintenance-friendly approach: themes and styles. A theme defines the overall idea of a design, like the current Material theme for Android. A style implements the design by defining a set of values for a view’s UI related properties. Styles and themes usually inherit most of their settings from a parent so you can focus on overriding what you get for free. This way a line like “android:textAppearance=”?android:attr/textAppearanceLarge”” completely defines a TextView’s look.

2. Extend WatchlistRecyclerViewAdapter and its ViewHolder

Now the code in WatchlistRecyclerViewAdapter.java needs some love. First the internal ViewHolder class gets 6 more fields to hold the new TextViews. After that WatchlistRecyclerViewAdapter.onBindViewHolder() connects those fields to their data:

public class WatchlistRecyclerViewAdapter
        extends RecyclerView.Adapter<WatchlistRecyclerViewAdapter.ViewHolder> {

	// ...

	@Override
	public void onBindViewHolder(final ViewHolder viewHolder, int quotePosition) {
		if (this.cursor.moveToPosition(quotePosition) == false) {
			throw new IllegalStateException(
					String.format(
							"%s.%s: cannot move to position = %d; cursor.getCount() = %d",
							WatchlistRecyclerViewAdapter.CLASS_NAME, "onBindViewHolder",
							quotePosition, this.cursor.getCount()));
		}
		float lastPrice = this.cursor.getFloat(
				this.cursor.getColumnIndex(QuoteContract.Quote.LAST_PRICE));
		// LastPriceDateTimeTextView
		viewHolder.LastPriceDateTimeTextView.setText(this.cursor.getString(
				this.cursor.getColumnIndex(QuoteContract.Quote.LAST_PRICE_DATE_TIME)));
		// LastPriceTextView
		String currency = this.cursor.getString(
				this.cursor.getColumnIndex(QuoteContract.Quote.CURRENCY));
		viewHolder.LastPriceTextView.setText(
				String.format("%01.2f %s", lastPrice, currency));

		// ...

		// region SignalTextView
		TextView signalTextView = viewHolder.SignalTextView;
		// If a trailing target is used, show an underscore
		Float trailingTargetPercentage = Utils.readFloatRespectingNull(
				SecurityContract.Security.TRAILING_TARGET, this.cursor);
		if (trailingTargetPercentage.isNaN() == false) {
			signalTextView.setPaintFlags(signalTextView.getPaintFlags()
					| Paint.UNDERLINE_TEXT_FLAG);
			signalTextView.setText("  ");
		} else {
			signalTextView.setPaintFlags(signalTextView.getPaintFlags()
					& ~Paint.UNDERLINE_TEXT_FLAG);
		}
		boolean isTrailingTargetReached
				= trailingTargetPercentage.isNaN() == false
				&& lastPrice <= maxPrice * (100 - trailingTargetPercentage) / 100; // Lower target boolean isLowerTargetReached = lowerTarget.isNaN() == false && lowerTarget >= lastPrice;
		// Upper target
		boolean isUpperTargetReached = upperTarget.isNaN() == false && upperTarget <= lastPrice;
		if (isLowerTargetReached
				|| isTrailingTargetReached
				|| isUpperTargetReached) {
			if (isLowerTargetReached) {
				signalTextView.setText("L");
			}
			if (isTrailingTargetReached) {
				signalTextView.setText("T");
			}
			if (isUpperTargetReached) {
				signalTextView.setText("U");
			} 
			signalTextView.setVisibility(View.VISIBLE);
		} else {
			if (trailingTargetPercentage.isNaN()) {
				signalTextView.setVisibility(View.GONE);
			}
		}
		// endregion SignalTextView

		// ...
	}

	private Float readFloatRespectingNull(String columnName, Cursor cursor) {
		Float result = Float.NaN;
		if (this.cursor.isNull(cursor.getColumnIndex(columnName)) == false) {
			result = cursor.getFloat(this.cursor.getColumnIndex(columnName));
		}
		return result;
	}

    public class ViewHolder extends RecyclerView.ViewHolder {
        public final TextView LastPriceDateTimeTextView;
        public final TextView LastPriceTextView;
        // ...
        public String Symbol;
        public final TextView SymbolTextView;
        public final View View;

        public ViewHolder(View view) {
            super(view);
            this.View = view;
            this.LastPriceDateTimeTextView
                    = (TextView) view.findViewById(R.id.lastPriceDateTimeTextView);
            this.LastPriceTextView = (TextView) view.findViewById(R.id.lastPriceTextView);
            // ...
        }

        @Override
        public String toString() {
            return super.toString() + " '" + SymbolTextView.getText() + "'";
        }
    }
}
Finished watchlist UI in designer

Finished watchlist UI in designer

Most of this just reads data from the cursor and copies it to the TextViews like before. As some of the database fields can contain null values now they have to be checked with cursor.isNull(). Be aware that reading a null value with cursor.getFloat() would give you a Float of 0.0. That’s how it should be because floats can’t have a non-value of null.

As float (small f) cannot hold a non-value those need to go into Float (capital F) which has Float.NaN (Not a Number) to represent them. Be shure to test for Float.NaN with variable.isNaN() as variable == Float.NaN will always be false. By definition a non-value cannot be equal or non-equal to any value even including itself.

Only lastPrice is a float guaranteed to be not null because DbTradeAlert ignores quotes without it specified. Suspicious values are read with readFloatRespectingNull() see lines 31 to 32 and 69 to 75.

Lines 33 to 40 show how to underline text while lines 58 and 61 show how to hide a View.

Add a quick sanity check before you move on:

  • Test the app – all the data is now shown; as the sample data has a trailing stop loss for both NESN.VX and NOVN.VX their signal fields show an underscore; the trailing stop loss (at 10 %) was already triggered for NOVN.VX (28.3 % below maximum price) resulting in an additional “T”
  • Optional: commit the changes

3. Add Color for Wins, Losses and Old Quotes

Sometimes DbTradeAlert won’t be able to get fresh quote data. May be there is no network available or there is simply no new data on weekends. In any case it should warn you so you don’t rely on possibly outdated data. It would also be nice to have colored signals so you know whether they signal a win or a loss.

The first step is to define 3 colors. For now their values could be hard-coded in WatchlistRecyclerViewAdapter because that’s the only code currently using them. But the chart will use those colors too so let’s define them in a central place right away:

  1. In Android Studio open app\src\main\res\values\colors.xml – it even shows the actual color on the leftmost side
  2. Add the 3 color elements shown below – the color scheme for wins and losses assumes long positions
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="colorPrimary">#3F51B5</color>
    <color name="colorPrimaryDark">#303F9F</color>
    <color name="colorAccent">#FF4081</color>
    <color name="colorLoss">#78FF0000</color>
    <color name="colorWarn">#78E09B1B</color>
    <color name="colorWin">#7800FF00</color>
</resources>

The next step is to find out if a quote is older than one day. Add this method to WatchlistRecyclerViewAdapter.java:

    private boolean isLastTradeOlderThanOneDay(Cursor cursor) {
        boolean result = false;
        int columnIndex = cursor.getColumnIndex(QuoteContract.Quote.LAST_TRADE_DATE_TIME);
        String s = cursor.getString(columnIndex);
        SimpleDateFormat format = new SimpleDateFormat(DbHelper.DATE_TIME_FORMAT_STRING);
        try {
            Date lastTradeDateTime = format.parse(s);
            Date oneDayAgo = new Date(System.currentTimeMillis() - (24 * 60 * 60 * 1000));
            if (lastTradeDateTime.before(oneDayAgo)) {
                result = true;
            }
        } catch (ParseException x) {
            Log.e(CLASS_NAME, "Exception caught", x);
        } catch (NullPointerException x) {
            // Assume null for missing time stamp
            Log.e(CLASS_NAME, "Exception caught", x);
        }
        return result;
    } // isLastTradeOlderThanOneDay()

This method creates a Java Date object from the string representing the last trade’s date and time. Of course the SimpleDateFormat has to match the format used in the database. The code then creates a Date object representing the time 24 hours ago, compares both, and returns the result.

Use the new method in onBindViewHolder():

    @Override
    public void onBindViewHolder(final ViewHolder viewHolder, int quotePosition) {
        // ...
        boolean isLastTradeOlderThanOneDay = this.isLastTradeOlderThanOneDay(cursor);
        // ...
        viewHolder.LastTradeDateTimeTextView.setText(cursor.getString(
                cursor.getColumnIndex(QuoteContract.Quote.LAST_TRADE_DATE_TIME)));
        if (isLastTradeOlderThanOneDay) {
            viewHolder.LastTradeDateTimeTextView.setBackgroundResource(R.color.colorWarn);
        } else {
            resetTextViewBackground(viewHolder.LastPriceDateTimeTextView);
        }
        // ...
    }

    // ...
    private void resetTextViewBackground(TextView textView) {
        if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.JELLY_BEAN) {
            textView.setBackgroundDrawable(null);
        } else {
            textView.setBackground(null);
        }
    } // resetTextViewBackground()

    // ...
}
Warn color applied to old quotes

Warn color applied to old quotes

For quotes older than 24 hours lastTradeDateTimeTextView’s background color is changed to warn about old data. As views get recycled it’s important to reset the color for fresh quotes – there is no drawable for TextView like android.R.drawable.edit_text for EditText by the way.

PercentDailyVolumeTextView get’s a similar treatment: warn you if no trades happened. And finally SignalTextView should warn you about signals possibly being triggered by old quotes. Additionally, hitting an upper target will be emphasized with a green background while hitting a lower or trailing target will result in a red background.

    @Override
    public void onBindViewHolder(final ViewHolder viewHolder, int quotePosition) {
        // ...
            if (isUpperTargetSignalTriggered) {
                signalTextView.setText("U");
                int resId = isLastTradeOlderThanOneDay ? R.color.colorWarn
                        : R.color.colorWin;
                signalTextView.setBackgroundResource(resId);
            } else {
                int resId = isLastTradeOlderThanOneDay ? R.color.colorWarn
                        : R.color.colorLoss;
                signalTextView.setBackgroundResource(resId);
            }
        // ...

Again a quick sanity check before you move on:

  • Test the app – both timestamps and the triggered signal now have a sepia background because they are outdated; without that the signal would have a red background
  • Optional: commit the changes

4. Add a Chart to Each Report

Each security’s report will be completed by a small chart … no, make that two:

  • The upper chart shows information related to the quote: previous close, open, last trade, ask and bid, day’s high and low
  • The lower chart shows information related to your targets: last trade, lower, trailing and upper target, base price

4.1 Get the Basics Working

Create a new Java class called “ReportChartView” for this beauty. Derive from View and let Android Studio implement 3 of the 4 constructors it offers (select the top 3 in the Choose Super Class Constructors window). The constructor taking an additional defStyleRes parameter would require API level 21 and is only used by subclasses which DbTradeAlert doesn’t have.

As usual let’s start with something simple and focus on integrating the new parts. Step one is to override onMeasure():

public class ReportChartView extends View {
    // ...
	
    private int getMeasureHeight(int heightMeasureSpec) {
        int resultingHeight;
        int desiredHeight = 100;
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        if (heightMode == MeasureSpec.EXACTLY) {
            // Must be this size
            resultingHeight = heightSize;
        } else if (heightMode == MeasureSpec.AT_MOST) {
            // Can't be bigger than...
            resultingHeight = Math.min(desiredHeight, heightSize);
        } else {
            // Be whatever you want
            resultingHeight = desiredHeight;
        }
        return resultingHeight;
    } // getMeasureHeight()
	
    // ...
	
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int measuredHeight = getMeasureHeight(heightMeasureSpec);
        int measuredWidth = getMeasureWidth(widthMeasureSpec);
        setMeasuredDimension(measuredWidth, measuredHeight);
    } // onMeasure()
}

Android calls onMeasure() basically to ask how much space the view would like to occupy and it answers with setMeasuredDimension(). This is especially important for ReportChartView because it will have an android:layout_height attribute value of “wrap_content”. As the layout provides no views inside ReportChartView there is no content and its height would default to 0 and so would never get any chance to render itself.

As you can see from getMeasureHeight() a view may have to negotiate the actual space to occupy.

Before ReportChartView can draw anything it needs to prepare its drawing paraphernalia:

public class QuoteChartView extends View {
    private static final String CLASS_NAME = "QuoteChartView";
    private Paint textPaint = null;

    // region ctors
    // Constructor required for in-code creation
    public QuoteChartView(Context context) {
        super(context);
        init();
    }

	// ...
	
    private void init() {
        this.textPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        this.textPaint.setColor(Color.GREEN);
        this.textPaint.setTextSize(50);
    }
	
	// ...
}

Of course init() needs to be called by all constructors. A word of caution: each quote has its own instance of ReportChartView, init() is called for every instance, and every instance will have its own set of private Paint objects. This is not a problem in DbTradeAlert but more demanding apps should use a static init() and fields. This way the Paint objects will only be created and stored once per app. Those apps will have to access the Context object through their Application of course and cannot use the one provided by each of the constructors.

Now ReportChartView is ready to draw:

public class QuoteChartView extends View {
	// ...
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        final String text = "GOOG goes up!";
        Rect boundsRect = new Rect();
        this.textPaint.getTextBounds(text, 0, text.length(), boundsRect);
        canvas.drawText(text, 1, -boundsRect.top + 1, this.textPaint);
        Log.v(CLASS_NAME, String.format(
                "%s(): canvas.Height = %d; canvas.Width = %d",
                "onDraw", canvas.getHeight(), canvas.getWidth()));
    }
	
	// ...
}
Drawing basics working

Drawing basics working

The 2nd and 3rd parameters of drawText() specify the output’s lower (!) left corner. That’s a leftover from the days of alphanumeric screens. Now you need to find out how much vertical space the text needs. While getTextBounds() gives you that information it has 2 pitfalls:

  • bottom and height() don’t take descends of characters like g or j into account but the base line
  • left actually has a value greater 0 because characters need some breathing room

And by the way: top is negative because it’s measured from the font’s baseline.

Finally include the view in fragment_report.xml:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content" xmlns:tools="http://schemas.android.com/tools">


	<!-- ... -->

    <TextView android:id="@+id/percentDailyVolumeTextView" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentRight="true" android:layout_below="@id/lastTradeDateTimeTextView" android:layout_marginLeft="7dp" android:layout_marginStart="7dp" android:textAppearance="?android:attr/textAppearanceSmall" android:layout_alignParentEnd="true" tools:text="158.2% V" />

    <de.dbremes.dbtradealert.ReportChartView android:id="@+id/reportChartView" android:layout_width="fill_parent" android:layout_height="wrap_content" android:layout_below="@id/percentDailyVolumeTextView" >
    </de.dbremes.dbtradealert.ReportChartView>
</RelativeLayout>

When you run the app now it shows a new green “GOOG goes up!” in each report.
Optional: commit the changes

4.2 Draw the First Chart

This section will explore how to pass data to ReportChartView and create a chart from it along the way.

The first step is to decide what scaling you’d like for the chart. Here are my thoughts:

  • I’m only interested in percentage gained / lost and not in absolute amounts
  • Using the same scale for all charts of a type in the watchlist makes them easier to decode at a glance
  • While the values for each quote will span a few percent the values for targets can span hundreds of percents so upper and lower chart need different scales

Let’s start with the quote chart. The first step will be to add the required method to DbHelper – getQuoteExtremesForWatchlist() will provide the maximum and minimum percentage of quote data relative to the respective quote’s last price for a watchlist:

public class DbHelper extends SQLiteOpenHelper {
    // ...

    public Extremes getQuoteExtremesForWatchlist(long watchlistId) {
        final String methodName = "getQuoteExtremesForWatchlist";
        Cursor cursor = null;
        SQLiteDatabase db = this.getReadableDatabase();
        String sql = "SELECT q." + Quote.ASK
                + ", q." + Quote.BID
                + ", q." + Quote.DAYS_HIGH
                + ", q." + Quote.DAYS_LOW
                + ", q." + Quote.LAST_PRICE
                + ", q." + Quote.OPEN
                + ", q." + Quote.PREVIOUS_CLOSE
                + "\nFROM " + Quote.TABLE + " q" + "\n\tINNER JOIN "
                + SecuritiesInWatchlists.TABLE + " siwl"
                + " ON siwl." + SecuritiesInWatchlists.SECURITY_ID
                + " = q." + Quote.SECURITY_ID
                + "\n\tINNER JOIN " + Watchlist.TABLE + " w"
                + " ON w." + Watchlist.ID + " = siwl." + SecuritiesInWatchlists.WATCHLIST_ID
                + "\nWHERE w." + Watchlist.ID + " = ?";
        String[] selectionArgs = new String[]{String.valueOf(watchlistId)};
        logSql(methodName, sql, selectionArgs);
        cursor = db.rawQuery(sql, selectionArgs);
        Log.v(DbHelper.CLASS_NAME,
                String.format(DbHelper.CURSOR_COUNT_FORMAT, methodName,
                        cursor.getCount()));
        List<String> columnNames = new ArrayList<String>();
        Collections.addAll(columnNames, Quote.ASK, Quote.BID, Quote.DAYS_HIGH,
                Quote.DAYS_LOW, Quote.OPEN, Quote.PREVIOUS_CLOSE);
        return getExtremesForCursor(columnNames, cursor);
    } // getQuoteExtremesForWatchlist()
}

The method just reads the required quote data from the database. SQL to get the min and max values for each quote and then determine the min and max values from the result would be way too complicated. So those are calculated by loops in getExtremesForCursor(). As that method returns 2 values it needs a class to hold them. And that class can also take care of calculating the percentages. If you prefer a quick and dirty solution like out or ref parameters: that doesn’t work in Java as it passes all parameters by value. Here is the class:

public class DbHelper extends SQLiteOpenHelper {
    // ...

    /**
     * ExtremesInfo holds information about the extremes of a quote or the signals of a security.
     * If last price is null then maxValue and minValue are stored as the extremes.
     * If last price is not null the extremes are calculated as percent of last price.
     * Example:
     * - last price = 100
     * - maximum value = 110
     * - minimum value = 90
     * -> maxPercent = 110 and minPercent = 90
     */
    public final class ExtremesInfo {
        private final Float maxPercent;
        private final Float minPercent;

        public ExtremesInfo(Float lastPrice, Float maxValue, Float minValue) {
            if (lastPrice == null) {
                this.maxPercent = maxValue;
                this.minPercent = minValue;
            } else {
                this.maxPercent = maxValue * 100 / lastPrice;
                this.minPercent = minValue * 100 / lastPrice;
            }
        }

        public Float getMaxPercent() {
            return maxPercent;
        }

        public Float getMinPercent() {
            return minPercent;
        }
    } // class ExtremesInfo
}

If lastPrice is provided the constructor calculates and stores the percentages represented by maxValue and minValue. Otherwise it just stores maxValue and minValue.

And here is getExtremesForCursor():

public class DbHelper extends SQLiteOpenHelper {
    // ...

    /**
     * Returns the minimum and maximum value of all columns contained in columnNames
     * from all records in cursor.
     * Minimum and maximum value are expressed as percentage of Quote.LAST_PRICE and cursor must
     * include this column as well as all columns in columnNames.
     * If columnNames includes Security.TRAILING_TARGET cursor needs to additionally include
     * Security.MAX_PRICE because the trailing target's current price is calculated from it.
     */
    private Extremes getExtremesForCursor(List<String> columnNames, Cursor cursor) {
        List<Extremes> extremes = new ArrayList<Extremes>();
        // Record extremes for each datarow
        while (cursor.moveToNext()) {
            Float lastPrice = cursor.getFloat(
                    cursor.getColumnIndex(QuoteContract.Quote.LAST_PRICE));
            Float maxPrice = lastPrice;
            Float minPrice = lastPrice;
            Float currentPrice;
            for (int columnIndex = 0; columnIndex < columnNames.size(); columnIndex++) { currentPrice = Utils.readFloatRespectingNull(columnNames.get(columnIndex), cursor); if (currentPrice.isNaN() == false && currentPrice > maxPrice) {
                    maxPrice = currentPrice;
                } else if (currentPrice.isNaN() == false && currentPrice < minPrice) {
                    minPrice = currentPrice;
                }
            }
            extremes.add(new Extremes(lastPrice, maxPrice, minPrice));
        }
        // Get extremes from recorded extremes
        Float maxPercent = 100f;
        Float minPercent = 100f;
        for (int i = 0; i < extremes.size(); i++) {
            if (extremes.get(i).getMaxPercent() > maxPercent) {
                maxPercent = extremes.get(i).getMaxPercent();
            }
            if (extremes.get(i).getMinPercent() < minPercent) {
                minPercent = extremes.get(i).getMinPercent();
            }
        }
        return new Extremes(null, maxPercent, minPercent);
    } // getExtremesForCursor()
}

The outer loop checks all the records and the inner checks their fields as the columnNames parameter dictates. The result is a list of Extremes objects that hold the minimum and maximum value as percentage of lastPrice for every record.

The helper readFloatRespectingNull() was originally a private method of WatchlistRecyclerViewAdapter. When DbHelper needed the same functionality I moved it as a static method into a new Utils class.

The 3rd loop then extracts the minimum and maximum percentages from the Extremes objects into a new Extremes object – no calculation required this time. Finally getQuoteExtremesForWatchlist() returns that object.

The next step is to extend ReportChartView:

  1. Add fields to hold the Extremes object as well as the quote data
  2. Add setValues() with respective parameters that stores them
  3. Add a linePaint object and initialize it like the textPaint object
  4. Extend onDraw() to draw the prices, a chart line, and mark the spread – or just return if executing in Android Studio’s designer because that cannot run it
  5. Add the required methods like drawPrice() and markSpread()

Note that onDraw() is critical to performance – avoid object allocations if possible.

public class ReportChartView extends View {
    // region private fields
    private static final String CLASS_NAME = "ReportChartView";
    private static final String naMarker = "-";
    private DbHelper.ExtremesInfo extremesInfo;
    private Float ask;
    private Float basePrice;
    private Float bid;
	// ...
    // region graphics objects
    private final int spreadMarkerHeight = 8;
    private final int yPadding = 4;
    // avoid allocation of object during onDraw():
    private Rect boundsRectTemp = new Rect();
    private Paint linePaint = null;
    private Paint textPaint = null;
    // endregion graphics objects
    // endregion private fields

	// ...

    private int drawPrice(Canvas canvas,
                          int currentY, float lastPrice, String marker, Float price, int width) {
        int result = 0;
        String valueString = "";
        if (price != Float.NaN) {
            float percent = getPercent(lastPrice, price);
            float currentX = getXPositionFromPercentage(percent, width);
            // Draw lastPrice centered above chart line
            valueString = String.format("%01.2f", price);
            this.textPaint.getTextBounds(
                    valueString, 0, valueString.length(), this.boundsRectTemp);
            if (price == lastPrice) {
                canvas.drawText(valueString,
                        currentX - this.boundsRectTemp.width() / 2,
                        currentY - this.boundsRectTemp.top, this.textPaint);
            }
            result += -this.boundsRectTemp.top + 2 * this.yPadding;
            // Draw marker centered below chart line
            if (marker.isEmpty() == false) {
                float markerWidth = this.textPaint.measureText(marker);
                float makerXPosition = currentX - markerWidth / 2;
                // Avoid marker getting cut off
                if (makerXPosition < 0) { makerXPosition = 0; } else if (makerXPosition > width - markerWidth) {
                    makerXPosition = width - markerWidth;
                }
                canvas.drawText(marker, makerXPosition,
                        result - this.boundsRectTemp.top + this.yPadding, this.textPaint);
            }
            result += -this.boundsRectTemp.top + 2 * this.yPadding;
        }
        return result;
    } // drawPrice()

	// ...

    private void init() {
        this.linePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        this.linePaint.setColor(Color.BLACK);
        this.linePaint.setStrokeWidth(2);
        this.textPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        this.textPaint.setColor(Color.BLACK);
        this.textPaint.setTextSize(30);
    } // init()

	// ...

    private void markSpread(Canvas canvas,
                            int lineY, Float ask, Float bid, float lastPrice, int width) {
        float askPercent = getPercent(lastPrice, ask);
        float askPosition = this.getXPositionFromPercentage(askPercent, width);
        float bidPercent = getPercent(lastPrice, bid);
        float bidPosition = this.getXPositionFromPercentage(bidPercent, width);
        canvas.drawRect(bidPosition, lineY - this.spreadMarkerHeight / 2,
                askPosition, lineY + this.spreadMarkerHeight / 2, this.linePaint);
    } // markSpread()

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        if (isInEditMode()) {
            return;
        }

        int width = getWidth();
        int currentY = this.yPadding;
        int outputHeight;
        // 1st chart shows quote data
        drawPrice(canvas, currentY, this.lastPrice, "a", this.ask, width);
        drawPrice(canvas, currentY, this.lastPrice, "b", this.bid, width);
        drawPrice(canvas, currentY, this.lastPrice, "H", this.daysHigh, width);
        drawPrice(canvas, currentY, this.lastPrice, "L", this.daysLow, width);
        drawPrice(canvas, currentY, this.lastPrice, "", this.lastPrice, width);
        drawPrice(canvas, currentY, this.lastPrice, "O", this.open, width);
        outputHeight = drawPrice(canvas, currentY, this.lastPrice, "P", this.previousClose, width);
        // Draw chart line
        int lineY = outputHeight / 2;
        canvas.drawLine(0, lineY, width, lineY, this.linePaint);
        if (ask.isNaN() == false && bid.isNaN() == false) {
            markSpread(canvas, lineY, ask, bid, lastPrice, width);
        }
        currentY += outputHeight + 2;
    } // onDraw()

	// ...

    public void setValues(DbHelper.Extremes quoteExtremes, 
                          Float ask, Float basePrice, Float bid,
                          Float daysHigh, Float daysLow, Float lastPrice, Float lowerTarget,
                          Float maxPrice, Float open, Float previousClose,
                          Float trailingTarget, Float upperTarget) {
        this.quoteExtremes = quoteExtremes;
        this.ask = ask;
        this.basePrice = basePrice;
		// ...
    } // setValues()
}
Quote chart drawn

Quote chart drawn

Then pass the data all the way to ReportChartView:

  1. In WatchlistRecyclerViewAdapter.onBindViewHolder() retrieve all the quote data from the cursor and call ReportChartView.setValues()
  2. Add a quoteExtremes field of type DbHelper.Extremes to WatchlistRecyclerViewAdapter and pass it to ReportChartView.setValues()
  3. Add that value as a parameter to WatchlistRecyclerViewAdapter’s constructor
  4. In WatchlistFragment.onCreateView() call getQuoteExtremesForWatchlist() and pass the Extremes object to WatchlistRecyclerViewAdapter’s constructor
public class WatchlistRecyclerViewAdapter
        extends RecyclerView.Adapter<WatchlistRecyclerViewAdapter.ViewHolder> {
    // Avoid warning "logging tag can be at most 23 characters ..."
    private static final String CLASS_NAME = "WatchlistRec.ViewAd.";
    private final DbHelper.ExtremesInfo extremesInfo;
    private final Cursor cursor;
    private final OnListFragmentInteractionListener listener;
	
    public WatchlistRecyclerViewAdapter(Cursor cursor, DbHelper.ExtremesInfo extremesInfo,
                                        OnListFragmentInteractionListener listener) {
        this.cursor = cursor;
        this.extremesInfo = extremesInfo;
        this.listener = listener;
    } // ctor()

    // ... 
	
    @Override
    public void onBindViewHolder(final ViewHolder viewHolder, int quotePosition) {
      // ... 
      // region QuoteChartView
      Float ask = readFloatRespectingNull(QuoteContract.Quote.ASK, this.cursor);
      Float basePrice
      		= readFloatRespectingNull(SecurityContract.Security.BASE_PRICE, this.cursor);
      Float bid = readFloatRespectingNull(QuoteContract.Quote.BID, this.cursor);
      Float daysHigh = readFloatRespectingNull(QuoteContract.Quote.DAYS_HIGH, this.cursor);
      Float daysLow = readFloatRespectingNull(QuoteContract.Quote.DAYS_LOW, this.cursor);
      Float lowerTarget
      		= readFloatRespectingNull(SecurityContract.Security.LOWER_TARGET, this.cursor);
      Float open = readFloatRespectingNull(QuoteContract.Quote.OPEN, this.cursor);
      Float previousClose
      		= readFloatRespectingNull(QuoteContract.Quote.PREVIOUS_CLOSE, this.cursor);
      Float upperTarget
      		= readFloatRespectingNull(SecurityContract.Security.UPPER_TARGET, this.cursor);
      viewHolder.ReportChartView.setValues(this.extremesInfo, ask, basePrice, bid,
      		daysHigh, daysLow, lastPrice, lowerTarget, maxPrice, open, previousClose,
      		upperTarget);
      // endregion QuoteChartView
      // ... 
	}
}
public class WatchlistFragment extends Fragment {
    // ...
	
    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
                             Bundle savedInstanceState) {
        View view = inflater.inflate(R.layout.fragment_watchlist, container, false);

        // Set the adapter
        if (view instanceof RecyclerView) {
            Context context = view.getContext();
            RecyclerView recyclerView = (RecyclerView) view;
            recyclerView.setLayoutManager(new LinearLayoutManager(context));
            dbHelper = new DbHelper(context);
            Cursor cursor = dbHelper.readAllQuotesForWatchlist(this.watchlistId);
            DbHelper.ExtremesInfo extremesInfo
                    = dbHelper.getQuoteExtremesForWatchlist(this.watchlistId);
            recyclerView.setAdapter(new WatchlistRecyclerViewAdapter(
                    cursor, extremesInfo, this.listener));
        }
        return view;
    }
}

Finally:

  • Run the app – instead of a simple “GOOG is up!” you’ll now get a chart with quote data in each report
  • Optional: commit the changes

4.3 Draw the Target Chart

Drawing the target chart builds on the work done for the quote chart:

  1. Add DbHelper.getTargetExtremesForWatchlist() which works like getSignalExtremesForWatchlist(); both could be combined into one method but performance is OK when hitting the database twice and it would make an even bigger string mess
  2. Update getExtremesForCursor() to handle the Security.TRAILING_TARGET field as the actual value needs to be computed from this and the Security.MAX_PRICE field
  3. Update WatchlistFragment.onCreateView() to additionally read the target extremes and pass them to WatchlistRecyclerViewAdapter’s constructor
  4. Add targetExtremes as a private field of type DbHelper.Extremes to WatchlistRecyclerViewAdapter
  5. Extend WatchlistRecyclerViewAdapter’s constructor to receive target extremes and store them
  6. In WatchlistRecyclerViewAdapter.onBindViewHolder() pass the field in viewHolder.ReportChartView.setValues()
  7. Add the same field to ReportChartView and extend setValues() to receive and store it
  8. Extend onDraw() to draw the target chart and include the win / loss as percentage as well as a rectangle in the respective color
public class DbHelper extends SQLiteOpenHelper {
    // ...

    private Extremes getExtremesForCursor(List<String> columnNames, Cursor cursor) {
        List<Extremes> extremes = new ArrayList<Extremes>();
        // Record extremes for each datarow
        while (cursor.moveToNext()) {
            Float lastPrice = cursor.getFloat(
                    cursor.getColumnIndex(QuoteContract.Quote.LAST_PRICE));
            Float maxPrice = lastPrice;
            Float minPrice = lastPrice;
            Float currentPrice;
            for (int columnIndex = 0; columnIndex < columnNames.size(); columnIndex++) { 
                if (columnNames.get(columnIndex) == Security.TRAILING_TARGET) { currentPrice = Float.NaN; Float trailingTarget = Utils.readFloatRespectingNull(columnNames.get(columnIndex), cursor); if (trailingTarget.isNaN() == false) { Float max = Utils.readFloatRespectingNull(Security.MAX_PRICE, cursor); if (max.isNaN() == false) { currentPrice = max - max * trailingTarget / 100; } } } else { currentPrice = Utils.readFloatRespectingNull(columnNames.get(columnIndex), cursor); } if (currentPrice.isNaN() == false && currentPrice > maxPrice) {
                    maxPrice = currentPrice;
                } else if (currentPrice.isNaN() == false && currentPrice < minPrice) {
                    minPrice = currentPrice;
                }
            }
            extremes.add(new Extremes(lastPrice, maxPrice, minPrice));
        }
   	// ...
        return new Extremes(null, maxPercent, minPercent);
    } // getExtremesForCursor()
	
    // ...
}
public class ReportChartView extends View {
    // ...
    private DbHelper.Extremes targetExtremes;

    // ...

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        // ...
        // Lower chart shows target data:
        // - Prices shown like in upper chart
        // - difference between basePrice and lastPrice is marked with green / red rectangle
        drawPrice(canvas, this.targetExtremes, currentY,
                this.lastPrice, "B", this.basePrice, width);
        outputHeight = drawPrice(canvas, this.targetExtremes, currentY,
                this.lastPrice, "", this.lastPrice, width);
        drawPrice(canvas, this.targetExtremes, currentY,
                this.lastPrice, "L", this.lowerTarget, width);
        drawPrice(canvas, this.targetExtremes, currentY,
                this.lastPrice, "T", this.trailingTarget, width);
        drawPrice(canvas, this.targetExtremes, currentY,
                this.lastPrice, "U", this.upperTarget, width);
        lineY = currentY + outputHeight / 2;
        canvas.drawLine(0, lineY, width, lineY, this.linePaint);
        if (this.basePrice.isNaN() == false) {
          // Show performance
          Float performance = (this.lastPrice - this.basePrice) * 100 / this.basePrice;
          Paint paint;
          if (performance > 0) {
            paint = this.winPaint;
          } else {
            paint = this.lossPaint;
          }
          markArea(canvas, this.targetExtremes, lineY,
              this.basePrice, this.lastPrice, this.lastPrice, paint, width);
          String performanceString = String.format("%01.2f%%", performance);
          this.textPaint.getTextBounds(performanceString, 0,
              performanceString.length(), this.boundsRectTemp);
          int centerX = (width - this.boundsRectTemp.width()) / 2;
          canvas.drawText(performanceString, centerX, lineY - 4, this.textPaint);
        }
    } // onDraw()
	
    // ...

    public void setValues(DbHelper.Extremes quoteExtremes, 
                          DbHelper.Extremes targetExtremes,
                          // ...
                          Float trailingTarget, Float upperTarget) {
        this.quoteExtremes = quoteExtremes;
        this.targetExtremes = targetExtremes;
       // ...
    } // setValues()
}
Target chart drawn

Target chart drawn

As markArea() is now responsible for marking the performance and not only the spread it was changed to generic parameter and variable names. And its drawRect() insists that left is more left than right which wasn’t a problem with bid and ask but required a change now, too.

Finally:

  • Run the app – it now shows two charts for each report
  • Optional: commit the changes

While it didn’t happen in the screenshot performance percent will draw over last price on the target chart. I don’t have much of a problem with that as I can check both values in other spots and it doesn’t affect the app’s main goal of alerting me to targets reached. If you do have a problem with that you’ll have to add a few lines of code to avoid collisions.

Next post: Update Quotes

Additional Resources

Advertisements
This entry was posted in Uncategorized and tagged , . Bookmark the permalink.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s