package com.evancharlton.mileage.models; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Calendar; import java.util.GregorianCalendar; import java.util.HashMap; import java.util.List; import java.util.Map; import android.content.ContentValues; import android.database.Cursor; import android.net.Uri; import com.evancharlton.mileage.FillUpsProvider; import com.evancharlton.mileage.R; import com.evancharlton.mileage.calculators.CalculationEngine; /** * Provide a data model to encapsulate the logic for a fill-up. Note that this * class is very lazy and will ideally never do calculations twice, cache * everything, and just generally be as efficient as possible so that callers * don't have to worry about caching stuff on their end. The convention this * class should use is that methods that begin with calc*() act like getters, * but have to do some calculation (most likely, involving database action). The * results of this calculation should be cached, so it should not be a * significant performance impact, but the caller should be aware of the * implications. * */ public class FillUp extends Model { public static final String PRICE = "cost"; // price per unit volume public static final String AMOUNT = "amount"; public static final String ODOMETER = "mileage"; // odometer, not economy public static final String DATE = "date"; // timestamp in milliseconds public static final String PARTIAL = "is_partial"; public static final String VEHICLE_ID = "vehicle_id"; public static final String LATITUDE = "latitude"; public static final String LONGITUDE = "longitude"; public static final String COMMENT = "comment"; public static final String RESTART = "restart"; public static final String AUTHORITY = "com.evancharlton.provider.Mileage"; public static final Uri CONTENT_URI = Uri.parse("content://" + AUTHORITY + "/fillups"); public static final String CONTENT_TYPE = "vnd.android.cursor.dir/vnd.evancharlton.fillup"; public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/vnd.evancharlton.fillup"; public static final String DEFAULT_SORT_ORDER = ODOMETER + " DESC"; public static final Map<String, String> PLAINTEXT = new HashMap<String, String>(); public static final List<String> PROJECTION = new ArrayList<String>(); static { PLAINTEXT.put(DATE, "Date"); PLAINTEXT.put(PRICE, "Price per gallon"); PLAINTEXT.put(AMOUNT, "Gallons of fuel"); PLAINTEXT.put(ODOMETER, "Odometer"); PLAINTEXT.put(VEHICLE_ID, "Vehicle"); PLAINTEXT.put(LATITUDE, "Latitude"); PLAINTEXT.put(LONGITUDE, "Longitude"); PLAINTEXT.put(COMMENT, "Fill-Up Comment"); PLAINTEXT.put(PARTIAL, "Partial Fill-up?"); PLAINTEXT.put(RESTART, "Restart calculations?"); PROJECTION.add(_ID); PROJECTION.add(PRICE); PROJECTION.add(AMOUNT); PROJECTION.add(ODOMETER); PROJECTION.add(DATE); PROJECTION.add(VEHICLE_ID); PROJECTION.add(LATITUDE); PROJECTION.add(LONGITUDE); PROJECTION.add(COMMENT); PROJECTION.add(PARTIAL); PROJECTION.add(RESTART); } private double m_odometer = 0; private Calendar m_date = GregorianCalendar.getInstance(); private double m_price = 0D; // per unit volume price private double m_amount = 0D; private double m_latitude = 0L; private double m_longitude = 0L; private String m_comment = ""; private long m_vehicleId = -1; private boolean m_partial = false; private double m_economy = 0D; private double m_distance = 0D; private Vehicle m_vehicle = null; private FillUp m_previous = null; private FillUp m_next = null; private CalculationEngine m_calculator = null; /** * Creates a blank FillUp with the specified CalculationEngine * * @param calculator CalculationEngine to use */ public FillUp(CalculationEngine calculator) { super(FillUpsProvider.FILLUPS_TABLE_NAME); m_calculator = calculator; } /** * Initialize a FillUp based on a set of ContentValues. This is likely to be * used when saving a new FillUp into the database. By initializing the data * before sending, we can perform sanity checks to make sure that there * isn't anything weird with the data before we commit it to the database. * * @param values A ContentValues mapping of the data to load. */ public FillUp(ContentValues values) { this((CalculationEngine) null); Double price = values.getAsDouble(PRICE); if (price != null) { setPrice(price); } Double odometer = values.getAsDouble(ODOMETER); if (odometer != null) { setOdometer(odometer); } Long time = values.getAsLong(DATE); if (time != null) { setDate(time); } Double amount = values.getAsDouble(AMOUNT); if (amount != null) { setAmount(amount); } Double latitude = values.getAsDouble(LATITUDE); if (latitude != null) { setLatitude(latitude); } Double longitude = values.getAsDouble(LONGITUDE); if (longitude != null) { setLongitude(longitude); } String comment = values.getAsString(COMMENT); if (comment != null) { setComment(comment); } Long vehicleId = values.getAsLong(VEHICLE_ID); if (vehicleId != null) { setVehicleId(vehicleId); } Integer isPartial = values.getAsInteger(PARTIAL); if (isPartial != null) { setPartial(isPartial == 1); } } /** * Create a new FillUp and have it initialize itself from the specified * database Cursor. Note that this should never change the cursor (it won't * move the cursor to the next row or anything), so the caller needs to be * aware of that. * * @param calculator The CalculationEngine to use when doing calculations * @param c The cursor to use to get the FillUp's information */ public FillUp(CalculationEngine calculator, Cursor c) { this(calculator); load(c); } /** * Load a FillUp based on its ID. * * @param calculator The CalculationEngine to use when doing calculations. * @param id The ID of the FillUp in the database. */ public FillUp(CalculationEngine calculator, long id) { this(calculator); String selection = _ID + " = ?"; String[] selectionArgs = new String[] { String.valueOf(id) }; String groupBy = null; String having = null; String orderBy = null; String[] projection = getProjection(); openDatabase(); Cursor c = m_db.query(FillUpsProvider.FILLUPS_TABLE_NAME, projection, selection, selectionArgs, groupBy, having, orderBy); if (c.getCount() == 1) { m_id = id; c.moveToFirst(); load(c); } closeDatabase(c); } private void load(Cursor c) { int index = c.getColumnIndex(VEHICLE_ID); if (index >= 0) { m_vehicleId = c.getLong(index); } index = c.getColumnIndex(_ID); if (index >= 0) { m_id = c.getLong(index); } index = c.getColumnIndex(PRICE); if (index >= 0) { m_price = c.getDouble(index); } index = c.getColumnIndex(AMOUNT); if (index >= 0) { m_amount = c.getDouble(index); } index = c.getColumnIndex(ODOMETER); if (index >= 0) { m_odometer = c.getDouble(index); } index = c.getColumnIndex(DATE); if (index >= 0) { setDate(c.getLong(index)); } index = c.getColumnIndex(LATITUDE); if (index >= 0) { m_latitude = c.getDouble(index); } index = c.getColumnIndex(LONGITUDE); if (index >= 0) { m_longitude = c.getDouble(index); } index = c.getColumnIndex(COMMENT); if (index >= 0) { m_comment = c.getString(index); } index = c.getColumnIndex(PARTIAL); if (index >= 0) { m_partial = c.getInt(index) == 1; } } /** * Get the Vehicle associated with this fill-up. * * @return the Vehicle associated with this fill-up. */ public Vehicle getVehicle() { if (m_vehicle == null) { m_vehicle = new Vehicle(m_vehicleId); } return m_vehicle; } /** * Calculates the fuel economy (based on the user's preferences) since the * previous fill-up. * * @return the fuel economy since the previous fill-up. */ public double calcEconomy() { if (m_partial) { return -1D; } if (m_economy == 0D) { FillUp previous = getPrevious(); if (previous == null) { return -1D; } double distance = calcDistance(); double fuel = getAmount(); while (previous != null) { if (previous.isPartial() == false) { break; } // partial; we need to keep iterating distance += previous.calcDistance(); fuel += previous.getAmount(); previous = previous.getPrevious(); } m_economy = m_calculator.calculateEconomy(distance, fuel); } return m_economy; } public double calcCostPerDistance() { FillUp previous = getPrevious(); if (previous == null) { return -1D; } double distance = calcDistance(); double cost = calcCost(); return cost / distance; } /** * Calculates the distance since the previous fill-up. * * @return the distance since the previous fill-up */ public double calcDistance() { if (m_distance == 0D) { m_previous = getPrevious(); if (m_previous == null) { // we're at the first fill-up, so there's nothing we can do return -1D; } m_distance = m_odometer - m_previous.getOdometer(); } return m_distance; } /** * Calculates the total cost for this fill-up * * @return the total cost for this fill-up */ public double calcCost() { return m_amount * m_price; } /** * Gets the next (higher mileage) fill-up (if necessary) and returns it. If * there isn't a next one, null is returned. * * @return The next fill-up */ public FillUp getNext() { if (m_next == null) { // get the ID for the next fill-up, if any // TODO: this and getPrevious() are basically identical; refactor it String selection = ODOMETER + " > ? AND " + VEHICLE_ID + " = ?"; String[] selectionArgs = new String[] { String.valueOf(m_odometer), String.valueOf(m_vehicleId) }; String orderBy = ODOMETER + " ASC, " + _ID + " ASC"; openDatabase(); Cursor c = m_db.query(FillUpsProvider.FILLUPS_TABLE_NAME, new String[] { _ID }, selection, selectionArgs, null, null, orderBy, "1"); if (c.getCount() == 1) { c.moveToFirst(); long id = c.getLong(0); closeDatabase(c); // we should close before we recurse m_next = new FillUp(m_calculator, id); } closeDatabase(c); // just in case the previous block didn't execute } return m_next; } public void setNext(FillUp next) { m_next = next; } /** * Gets the previous (lower mileage) fill-up (if necessary) and returns it. * If there isn't a previous one, null is returned. * * @return The previous fill-up */ public FillUp getPrevious() { if (m_previous == null) { // get the ID for the previous fill-up, if any // TODO: this and getNext() are basically identical; refactor it String selection = ODOMETER + " < ? AND " + VEHICLE_ID + " = ?"; String[] selectionArgs = new String[] { String.valueOf(m_odometer), String.valueOf(m_vehicleId) }; String orderBy = ODOMETER + " DESC, " + _ID + " DESC"; openDatabase(); Cursor c = m_db.query(FillUpsProvider.FILLUPS_TABLE_NAME, new String[] { _ID }, selection, selectionArgs, null, null, orderBy, "1"); if (c.getCount() == 1) { c.moveToFirst(); long id = c.getLong(0); closeDatabase(c); // we should close before we recurse m_previous = new FillUp(m_calculator, id); } closeDatabase(c); // just in case the previous block didn't execute } return m_previous; } public void setPrevious(FillUp previous) { m_previous = previous; } public static String[] getProjection() { return PROJECTION.toArray(new String[PROJECTION.size()]); } @Override public long save() { openDatabase(); ContentValues values = new ContentValues(); values.put(AMOUNT, m_amount); values.put(COMMENT, m_comment); values.put(PRICE, m_price); values.put(DATE, m_date.getTimeInMillis()); values.put(LATITUDE, m_latitude); values.put(LONGITUDE, m_longitude); values.put(ODOMETER, m_odometer); values.put(VEHICLE_ID, m_vehicleId); values.put(PARTIAL, m_partial); if (m_id == -1) { // save a new record m_id = m_db.insert(FillUpsProvider.FILLUPS_TABLE_NAME, null, values); } else { // update an existing record m_db.update(FillUpsProvider.FILLUPS_TABLE_NAME, values, _ID + " = ?", new String[] { String.valueOf(m_id) }); } closeDatabase(null); return m_id; } @Override public int validate() { if (m_odometer == 0D) { return R.string.error_mileage; } else if (m_amount == 0D) { return R.string.error_amount; } else if (m_price == 0D) { return R.string.error_cost; } else if (m_vehicleId == -1) { return R.string.error_vehicle; } return -1; } public static String[] getCSVColumns() { String[] cols = new String[PLAINTEXT.size()]; int i = 0; for (String key : PLAINTEXT.keySet()) { cols[i++] = PLAINTEXT.get(key); } return cols; } public String[] toCSV(final String[] columns) { final int size = columns.length; String[] data = new String[size]; String col; for (int i = 0; i < size; i++) { col = columns[i]; if (col.equals(FillUp.AMOUNT) || col.equals(FillUp.PLAINTEXT.get(FillUp.AMOUNT))) { data[i] = String.valueOf(m_amount); } else if (col.equals(FillUp.COMMENT) || col.equals(FillUp.PLAINTEXT.get(FillUp.COMMENT))) { data[i] = m_comment; } else if (col.equals(FillUp.DATE) || col.equals(FillUp.PLAINTEXT.get(FillUp.DATE))) { data[i] = String.valueOf(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(m_date.getTime())); } else if (col.equals(FillUp.LATITUDE) || col.equals(FillUp.PLAINTEXT.get(FillUp.LATITUDE))) { data[i] = String.valueOf(m_latitude); } else if (col.equals(FillUp.LONGITUDE) || col.equals(FillUp.PLAINTEXT.get(FillUp.LONGITUDE))) { data[i] = String.valueOf(m_longitude); } else if (col.equals(FillUp.ODOMETER) || col.equals(FillUp.PLAINTEXT.get(FillUp.ODOMETER))) { data[i] = String.valueOf(m_odometer); } else if (col.equals(FillUp.PARTIAL) || col.equals(FillUp.PLAINTEXT.get(FillUp.PARTIAL))) { data[i] = m_partial ? "1" : "0"; } else if (col.equals(FillUp.PRICE) || col.equals(FillUp.PLAINTEXT.get(FillUp.PRICE))) { data[i] = String.valueOf(m_price); } else if (col.equals(FillUp.VEHICLE_ID) || col.equals(FillUp.PLAINTEXT.get(FillUp.VEHICLE_ID))) { data[i] = String.valueOf(m_vehicleId); } } return data; } /** * @param odometer the odometer to set */ public void setOdometer(double odometer) { m_odometer = odometer; } /** * Set odometer in string format, so that patterns can be parsed out (such * as the '+' prefix notation. Note that if you are using this feature, the * caller needs to set the vehicle ID before calling this in order to get * the previous odometer value! Also, remember that this method is not free, * due to the additional database lookup(s)! * * @param odometer The string representing the odometer value */ public void setOdometer(String odometer) throws NumberFormatException { if (m_vehicleId < 0) { throw new IllegalStateException("Need to set vehicle ID before calling setOdometer(String odometer)!"); } if (odometer.startsWith("+")) { // m_odometer must be maxed out for getPrevious() to work. m_odometer = Double.MAX_VALUE; FillUp previous = getPrevious(); if (previous == null) { setOdometer(odometer.substring(1)); return; } m_odometer = previous.getOdometer() + Double.parseDouble(odometer.substring(1)); } else { m_odometer = Double.parseDouble(odometer); } } /** * @return the odometer */ public double getOdometer() { return m_odometer; } /** * @param date the date to set */ public void setDate(Calendar date) { m_date = date; } public void setDate(long timestamp) { Calendar cal = Calendar.getInstance(); cal.setTimeInMillis(timestamp); setDate(cal); } /** * Set the date in month/day/year format * * @param year the year * @param month the month * @param day the day */ public void setDate(int day, int month, int year) { m_date.set(year, month, day); } /** * @return the date */ public Calendar getDate() { return m_date; } /** * @param price the price per unit volume */ public void setPrice(double price) { m_price = price; } /** * @return the price per unit volume */ public double getPrice() { return m_price; } /** * @param amount the amount to set */ public void setAmount(double amount) { m_amount = amount; } /** * @return the amount */ public double getAmount() { return m_amount; } /** * @param comment the comment to set */ public void setComment(String comment) { if (comment != null) { m_comment = comment.trim(); } } /** * @return the comment */ public String getComment() { return m_comment; } /** * @param latitude the latitude to set */ public void setLatitude(double latitude) { m_latitude = latitude; } /** * @return the latitude */ public double getLatitude() { return m_latitude; } /** * @param longitude the longitude to set */ public void setLongitude(double longitude) { m_longitude = longitude; } /** * @return the longitude */ public double getLongitude() { return m_longitude; } /** * @return the vehicleId */ public long getVehicleId() { return m_vehicleId; } /** * @param vehicleId the vehicleId to set */ public void setVehicleId(long vehicleId) { m_vehicleId = vehicleId; } /** * Set the Vehicle */ public void setVehicle(Vehicle v) { m_vehicle = v; setVehicleId(v.getId()); } /** * @param the CalculationEngine to use */ public void setCalculationEngine(CalculationEngine calculator) { m_calculator = calculator; } /** * @return the CalculationEngine being used */ public CalculationEngine getCalculationEngine() { return m_calculator; } /** * Sets the total cost per fill-up. If one (but not both) of either volume * or price is already set, the other will be calculated. If both or neither * are set, then nothing is done. Keep in mind that for in order for the * auto-calculation to work, the other value needs to be set first. * * @param cost the total cost of the fill-up */ public void setCost(double cost) { if (m_amount == 0D && m_price != 0D) { m_amount = cost / m_price; } else if (m_amount != 0D && m_price == 0D) { m_price = cost / m_amount; } } public void setPartial(boolean partial) { m_partial = partial; } public void setPartial(int partial) { setPartial(partial == 1); } public boolean isPartial() { return m_partial; } }