Back to project page keepscore-android.
The source code is released under:
GNU General Public License
If you think the Android project keepscore-android listed in this page is inappropriate, such as containing malicious code/tools or violating the copyright, please email info at java2s dot com, thanks.
/* Keep Score: keep track of player scores during a card game. Copyright (C) 2009 Michael Elsdrfer <http://elsdoerfer.name> /*ww w .ja v a 2 s.co m*/ This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>. */ package com.elsdoerfer.keepscore; import java.lang.reflect.Array; import java.util.ArrayList; import android.app.Activity; import android.database.Cursor; import android.graphics.Typeface; import android.os.Bundle; import android.text.Editable; import android.text.method.DigitsKeyListener; import android.view.Gravity; import android.view.KeyEvent; import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.widget.Button; import android.widget.EditText; import android.widget.ScrollView; import android.widget.TableLayout; import android.widget.TableRow; import android.widget.TextView; public class Game extends Activity { // number of rows that are required in the table before // we automatically repeat the player names at the table // bottom, because the top might no longer be on screen. private static int NUM_ROWS_FOR_FOOTER = 10; // resources protected Typeface mBoldFace; protected int mCellPadding; // menu items public static final int REMOVE_LAST_ROW_ID = Menu.FIRST; public static final int END_GAME_ID = Menu.FIRST + 1; protected MenuItem mRemoveLastRowItem; protected MenuItem mEndGameItem; // static views protected ScrollView mGameScrollView; protected TableLayout mGameTable; protected Button mAddNewScoresButton; // dynamic views protected TableRow mHeaderRow; protected TableRow mFooterRow; protected EditText[] mNewScoreEdits; DbAdapter mDb = new DbAdapter(this); // TODO: init with null? // the session we are currently playing protected Long mSessionId; // the session's data, stored temporary in local memory protected String[] mPlayers; protected ArrayList<Integer[]> mScoreMatrix; // Holds the user-entered or automatically calculated // values for the next new row scores. protected Integer[] mNewScoreValues; // The value the user previously entered into a score field. // Used to prefill the fields he might enter next with a default. protected CharSequence mLastEnteredValue = null; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.game); // setup database connection mDb = new DbAdapter(this); mDb.open(); // initialize data from state bundle or passed row mSessionId = savedInstanceState != null ? savedInstanceState.getLong(DbAdapter.SESSION_ID_KEY) : null; if (mSessionId == null) { Bundle extras = getIntent().getExtras(); mSessionId = extras.getLong(DbAdapter.SESSION_ID_KEY); } // load list of players for this game mPlayers = mDb.fetchSessionPlayerNames(mSessionId); // get views mGameScrollView = (ScrollView)findViewById(R.id.game_container); mGameTable = (TableLayout)findViewById(R.id.game); mAddNewScoresButton = (Button)findViewById(R.id.add_new_scores); TableRow.LayoutParams params = (TableRow.LayoutParams)mAddNewScoresButton.getLayoutParams(); params.span = mPlayers.length; mAddNewScoresButton.setLayoutParams(params); // load resources mBoldFace = Typeface.defaultFromStyle(Typeface.BOLD); mCellPadding = getResources().getDimensionPixelSize(R.dimen.game_table_padding); // create header row, listing the names of the players mHeaderRow = makeTextRow(mPlayers, true); mGameTable.addView(mHeaderRow, 0); // create the edit row, allows adding new scores TableRow editRow = new TableRow(this); mNewScoreEdits = (EditText[]) Array.newInstance(EditText.class, mPlayers.length); mNewScoreValues = (Integer[]) Array.newInstance(Integer.class, mPlayers.length); for (int i=0; i<mPlayers.length; i++) { EditText edit = new EditText(this); edit.setGravity(Gravity.CENTER); // This is a really, really bad hack. We want hours dynamically // created edits to store state, but this only happens automatically // if they have an id. It seems like we can't dynamically generate // "real" id resources (or can we?), so we just make some up. For // now it seems to work without side effects. I'd love to see a better // solution, but doing the storage all manually seems like a lot of // work and hard to get right (there's a lot to store, selection, // focus etc). // We use 0x7f99..., generated IDs seem to start with 0x7f08... edit.setId(0x7f99006 + i); // We'd want single-line, most importantly since <enter> would then // jump to the submit button automatically, but alas, there seems to // be a bug in Android 1.0 which causes the hint-text not to show // if single line is enabled. So for now, don't. // edit.setSingleLine(true); // Use a DigitsKeyListener to only allow digits, plus add some // custom key handling. edit.setKeyListener(new DigitsKeyListener(true, false) { @Override public boolean onKeyDown(View view, Editable text, int keyCode, KeyEvent event) { return super.onKeyDown(view, text, keyCode, event); } @Override public boolean onKeyUp(View view, Editable text, int keyCode, KeyEvent event) { // If the user presses enter, jump to the submit button // below. It would be normal behavior if single-line // where true, but we cannot use that due to a bug in // Android (hints would not show). switch (keyCode) { case KeyEvent.KEYCODE_ENTER: if (mAddNewScoresButton.isEnabled()) { mAddNewScoresButton.requestFocus(); return true; } // ..else fall down into KEYCODE_TAB behavior. case KeyEvent.KEYCODE_TAB: View v = view.focusSearch(View.FOCUS_RIGHT); if (v==null) v = view.focusSearch(View.FOCUS_DOWN); if (v!=null) v.requestFocus(View.FOCUS_FORWARD); return true; } boolean result = super.onKeyUp(view, text, keyCode, event); // update user interface to this change updateUI(); return result; } }); edit.setOnFocusChangeListener(new View.OnFocusChangeListener() { public void onFocusChange(View v, boolean hasFocus) { // When the user enters an edit field, we prefill // it with the value of the previous field the user // entered text in. This allows for example the // following workflow: Players A, B, C, D. A and B // get awarded 50 points, C and D lose 50 points. // User focuses edit A, types 50, focuses field B, // field will be set to 50 by this code, user can // add the score right away. EditText edit = (EditText)v; if (edit.isEnabled()) { // apparently this gets triggered for disabled fields as well?! if (!edit.hasFocus()) { mLastEnteredValue = edit.getText(); } else if (mLastEnteredValue != null) { if (edit.getText().length() == 0) { edit.setText(mLastEnteredValue); // TODO: selectAll doesn't have an effect // when entering the field via touch, // probably because it gets overridden // right afterwards. edit.selectAll(); } } } // Some error messages need to update when the // focus changes, see updateUI comments for more info. updateUI(); } }); editRow.addView(edit); mNewScoreEdits[i] = edit; mNewScoreValues[i] = null; } mGameTable.addView(editRow, 1); mScoreMatrix = new ArrayList<Integer[]>(); // add existing data rows Cursor scoreStream = mDb.fetchSessionScores(mSessionId); try { if (scoreStream.getCount() > 0) { scoreStream.moveToFirst(); Integer[] currentRow = new Integer[mPlayers.length]; Integer currentPos = 0; do { currentRow[currentPos] = scoreStream.getInt(0); currentPos++; if (currentPos == currentRow.length) { insertScoreRow(currentRow); currentPos = 0; } } while (scoreStream.moveToNext()); if (currentPos != 0) { // Something is wrong with the database, // there was an invalid number of tokens // in the stream. This shouldn't happen. throw new java.lang.IndexOutOfBoundsException( "Score stream ended unexpectedly. The " + "session data is faulty."); } } } finally { scoreStream.close(); } // setup event handlers mAddNewScoresButton.setOnClickListener(new View.OnClickListener() { public void onClick(View v) { // Add new row of scores. // We trust that mNewScoreValues contains no null values. // The submit button should not be enabled if this is not // the case. mDb.addSessionScores(mSessionId, mNewScoreValues); insertScoreRow(mNewScoreValues); // clear existing input values. for (int i=0; i<mNewScoreValues.length; i++) { mNewScoreValues[i] = null; mNewScoreEdits[i].setText(""); mLastEnteredValue = null; } // update UI - for some reason, this is one of the few // ways we actually managed to scroll to the very bottom. // In particular, using "fullScroll(FOCUS_DOWN)" never // scrolled the submit button fully into view, and neither // did the non-smooth scrolling methods. mGameScrollView.smoothScrollBy(0, mGameScrollView.getHeight()); mNewScoreEdits[0].requestFocus(); updateUI(); } }); // initial UI initialization updateUI(); } @Override public void onDestroy() { super.onDestroy(); mDb.close(); } @Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); outState.putLong(DbAdapter.SESSION_ID_KEY, mSessionId); } @Override protected void onPause() { super.onPause(); mDb.updateSessionTimestamp(mSessionId); } @Override protected void onResume() { super.onResume(); mDb.updateSessionTimestamp(mSessionId); } /** * Make a new row of TextView objects that can be added to the * table. * * Used to create the score rows for each round, as well as * header rows. */ protected TableRow makeTextRow(Object[] values, boolean header) { TableRow newRow = new TableRow(this); for (Object value : values) { TextView text = new TextView(this); text.setText(value.toString()); text.setGravity(Gravity.CENTER); if (header) text.setTypeface(mBoldFace); text.setPadding(mCellPadding, mCellPadding, mCellPadding, mCellPadding); newRow.addView(text); } return newRow; } protected void insertScoreRow(Integer[] scores) { scores = scores.clone(); // copy, to allow caller to reuse his object mScoreMatrix.add(scores); // "scores" is the latest change, but we need to display // the current sum for each player, so create an array // that contains the updated sum values. Integer[] currentSums = new Integer[scores.length]; for (int i=0; i<mScoreMatrix.size(); i++) { Integer[] aRow = mScoreMatrix.get(i); for (int j=0; j<aRow.length; j++) if (currentSums[j] == null) currentSums[j] = aRow[j]; else currentSums[j] += aRow[j]; } mGameTable.addView(makeTextRow(currentSums, false), getInsertPosition()); // After a certain number of rounds, repeat the player // names at the bottom. We could also insert something // after every X rows (though we then should do it // *before* we insert the score row, so that the bottom // most row is always scores). if (mScoreMatrix.size() == NUM_ROWS_FOR_FOOTER) { mGameTable.addView(makeTextRow(mPlayers, true), mGameTable.getChildCount()-2); } } protected int getInsertPosition() { // The position where we insert new score rows changes // depending on whether we have a footer or not. return mGameTable.getChildCount()- (mScoreMatrix.size() > NUM_ROWS_FOR_FOOTER ? 3 : 2); } protected void updateUI() { // Calculate automatic values for the player // the user did not give a score himself. int numManualScores = 0; int sumManualScores = 0; for (int i=0; i<mNewScoreEdits.length; i++) { EditText scoreEdit = mNewScoreEdits[i]; String stringValue = scoreEdit.getText().toString(); if (stringValue.length() != 0) try { int intValue = Integer.parseInt(stringValue); mNewScoreValues[i] = intValue; sumManualScores += intValue; numManualScores++; } catch (NumberFormatException e) { // Android's error message functionality is nice, // but because it is reset every time the user // changes the text, and this could would immediately // set the error again, you'd see the error popup // constantly hiding/showing as the user types, which // doesn't look good and is slow. So our hacky solution // is to only show set the error if the edit does not // have focus, i.e. once it loses focus. if (!scoreEdit.hasFocus()) scoreEdit.setError(getResources().getString(R.string.not_a_valid_number)); } else { // This particular field has no explicit value, indicate // as much so that it will later be calculated. mNewScoreValues[i] = null; } } // Provide default values for the fields currently empty. int numAutomaticValues = mNewScoreValues.length-numManualScores; int sumAutomaticValues = 0; Integer lastSetIndex = null; for (int i=0; i<mNewScoreEdits.length; i++) { EditText scoreEdit = mNewScoreEdits[i]; if (!scoreEdit.isEnabled()) scoreEdit.setEnabled(true); // Nothing to suggest if not a single value was provided // by the user; simply clear all automatic values. if (numManualScores<=0) { scoreEdit.setHint(null); mNewScoreValues[i] = null; } else { // if this is an empty field, provide it with an automatic value if (scoreEdit.getText().length() == 0) { int suggestedValue = -(sumManualScores / numAutomaticValues); scoreEdit.setHint(String.valueOf(suggestedValue)); mNewScoreValues[i] = suggestedValue; sumAutomaticValues += suggestedValue; lastSetIndex = i; // If there is only one automatic field left, disable it. // This prevents the user from entering unbalanced values. if (numAutomaticValues==1) scoreEdit.setEnabled(false); } } } // We want rows to be balanced, so to account for rounding errors, we // will adjust the value of the last field that we provided a value for. int roundingError = sumAutomaticValues + sumManualScores; if (roundingError != 0) { mNewScoreValues[lastSetIndex] -= roundingError; mNewScoreEdits[lastSetIndex].setHint(String.valueOf(mNewScoreValues[lastSetIndex])); } // enable submit button if the user provided at least one score mAddNewScoresButton.setEnabled(numManualScores>0); // can only remove a row if there actually is one if (mRemoveLastRowItem!=null) mRemoveLastRowItem.setEnabled(mScoreMatrix.size()>0); } @Override public boolean onCreateOptionsMenu(Menu menu) { super.onCreateOptionsMenu(menu); mRemoveLastRowItem = menu.add(0, REMOVE_LAST_ROW_ID, 0, R.string.remove_last_row); mRemoveLastRowItem.setIcon(R.drawable.ic_menu_revert); mEndGameItem = menu.add(0, END_GAME_ID, 0, R.string.end_game); mEndGameItem.setIcon(R.drawable.ic_menu_close_clear_cancel); // setup initial visibilities updateUI(); return true; } public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { case REMOVE_LAST_ROW_ID: Integer[] lastRow = mScoreMatrix.get(mScoreMatrix.size()-1); // Attention! The order of the following calls is vital, // since each affects the indexes that the others are using. if (mScoreMatrix.size() == NUM_ROWS_FOR_FOOTER) // need to make sure that we remove the footer at // the right moment as well mGameTable.removeViewAt(mGameTable.getChildCount()-3); // Note: assumes that the row ids in the database // match the row indices here. as long as rows are // not removed in between, this should be true. mDb.removeSessionScores(mSessionId, getInsertPosition()-1); mGameTable.removeViewAt(getInsertPosition()-1); mScoreMatrix.remove(lastRow); return true; case END_GAME_ID: finish(); return true; } return false; } }