diff options
Diffstat (limited to 'src/com/android/settings/widget')
-rw-r--r-- | src/com/android/settings/widget/ChartAxis.java | 38 | ||||
-rw-r--r-- | src/com/android/settings/widget/ChartGridView.java | 102 | ||||
-rw-r--r-- | src/com/android/settings/widget/ChartNetworkSeriesView.java | 222 | ||||
-rw-r--r-- | src/com/android/settings/widget/ChartSweepView.java | 437 | ||||
-rw-r--r-- | src/com/android/settings/widget/ChartView.java | 119 | ||||
-rw-r--r-- | src/com/android/settings/widget/DataUsageChartView.java | 406 | ||||
-rw-r--r-- | src/com/android/settings/widget/InvertedChartAxis.java | 67 |
7 files changed, 1391 insertions, 0 deletions
diff --git a/src/com/android/settings/widget/ChartAxis.java b/src/com/android/settings/widget/ChartAxis.java new file mode 100644 index 0000000..463541f --- /dev/null +++ b/src/com/android/settings/widget/ChartAxis.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settings.widget; + +import android.content.res.Resources; +import android.text.SpannableStringBuilder; + +/** + * Axis along a {@link ChartView} that knows how to convert between raw point + * and screen coordinate systems. + */ +public interface ChartAxis { + + public void setBounds(long min, long max); + public void setSize(float size); + + public float convertToPoint(long value); + public long convertToValue(float point); + + public void buildLabel(Resources res, SpannableStringBuilder builder, long value); + + public float[] getTickPoints(); + +} diff --git a/src/com/android/settings/widget/ChartGridView.java b/src/com/android/settings/widget/ChartGridView.java new file mode 100644 index 0000000..7a83fbf --- /dev/null +++ b/src/com/android/settings/widget/ChartGridView.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settings.widget; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.view.View; + +import com.android.settings.R; +import com.google.common.base.Preconditions; + +/** + * Background of {@link ChartView} that renders grid lines as requested by + * {@link ChartAxis#getTickPoints()}. + */ +public class ChartGridView extends View { + + // TODO: eventually teach about drawing chart labels + + private ChartAxis mHoriz; + private ChartAxis mVert; + + private Drawable mPrimary; + private Drawable mSecondary; + private Drawable mBorder; + + public ChartGridView(Context context) { + this(context, null, 0); + } + + public ChartGridView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public ChartGridView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + + setWillNotDraw(false); + + final TypedArray a = context.obtainStyledAttributes( + attrs, R.styleable.ChartGridView, defStyle, 0); + + mPrimary = a.getDrawable(R.styleable.ChartGridView_primaryDrawable); + mSecondary = a.getDrawable(R.styleable.ChartGridView_secondaryDrawable); + mBorder = a.getDrawable(R.styleable.ChartGridView_borderDrawable); + // TODO: eventually read labelColor + + a.recycle(); + } + + void init(ChartAxis horiz, ChartAxis vert) { + mHoriz = Preconditions.checkNotNull(horiz, "missing horiz"); + mVert = Preconditions.checkNotNull(vert, "missing vert"); + } + + @Override + protected void onDraw(Canvas canvas) { + final int width = getWidth(); + final int height = getHeight(); + + final Drawable secondary = mSecondary; + final int secondaryHeight = mSecondary.getIntrinsicHeight(); + + final float[] vertTicks = mVert.getTickPoints(); + for (float y : vertTicks) { + final int bottom = (int) Math.min(y + secondaryHeight, height); + secondary.setBounds(0, (int) y, width, bottom); + secondary.draw(canvas); + } + + final Drawable primary = mPrimary; + final int primaryWidth = mPrimary.getIntrinsicWidth(); + final int primaryHeight = mPrimary.getIntrinsicHeight(); + + final float[] horizTicks = mHoriz.getTickPoints(); + for (float x : horizTicks) { + final int right = (int) Math.min(x + primaryWidth, width); + primary.setBounds((int) x, 0, right, height); + primary.draw(canvas); + } + + mBorder.setBounds(0, 0, width, height); + mBorder.draw(canvas); + } +} diff --git a/src/com/android/settings/widget/ChartNetworkSeriesView.java b/src/com/android/settings/widget/ChartNetworkSeriesView.java new file mode 100644 index 0000000..10d8976 --- /dev/null +++ b/src/com/android/settings/widget/ChartNetworkSeriesView.java @@ -0,0 +1,222 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settings.widget; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Paint.Style; +import android.graphics.Path; +import android.graphics.RectF; +import android.net.NetworkStatsHistory; +import android.util.AttributeSet; +import android.util.Log; +import android.view.View; + +import com.android.settings.R; +import com.google.common.base.Preconditions; + +/** + * {@link NetworkStatsHistory} series to render inside a {@link ChartView}, + * using {@link ChartAxis} to map into screen coordinates. + */ +public class ChartNetworkSeriesView extends View { + private static final String TAG = "ChartNetworkSeriesView"; + private static final boolean LOGD = true; + + private ChartAxis mHoriz; + private ChartAxis mVert; + + private Paint mPaintStroke; + private Paint mPaintFill; + private Paint mPaintFillSecondary; + + private NetworkStatsHistory mStats; + + private Path mPathStroke; + private Path mPathFill; + + private long mPrimaryLeft; + private long mPrimaryRight; + + public ChartNetworkSeriesView(Context context) { + this(context, null, 0); + } + + public ChartNetworkSeriesView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public ChartNetworkSeriesView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + + final TypedArray a = context.obtainStyledAttributes( + attrs, R.styleable.ChartNetworkSeriesView, defStyle, 0); + + final int stroke = a.getColor(R.styleable.ChartNetworkSeriesView_strokeColor, Color.RED); + final int fill = a.getColor(R.styleable.ChartNetworkSeriesView_fillColor, Color.RED); + final int fillSecondary = a.getColor( + R.styleable.ChartNetworkSeriesView_fillColorSecondary, Color.RED); + + setChartColor(stroke, fill, fillSecondary); + setWillNotDraw(false); + + a.recycle(); + + mPathStroke = new Path(); + mPathFill = new Path(); + } + + void init(ChartAxis horiz, ChartAxis vert) { + mHoriz = Preconditions.checkNotNull(horiz, "missing horiz"); + mVert = Preconditions.checkNotNull(vert, "missing vert"); + } + + public void setChartColor(int stroke, int fill, int fillSecondary) { + mPaintStroke = new Paint(); + mPaintStroke.setStrokeWidth(6.0f); + mPaintStroke.setColor(stroke); + mPaintStroke.setStyle(Style.STROKE); + mPaintStroke.setAntiAlias(true); + + mPaintFill = new Paint(); + mPaintFill.setColor(fill); + mPaintFill.setStyle(Style.FILL); + mPaintFill.setAntiAlias(true); + + mPaintFillSecondary = new Paint(); + mPaintFillSecondary.setColor(fillSecondary); + mPaintFillSecondary.setStyle(Style.FILL); + mPaintFillSecondary.setAntiAlias(true); + } + + public void bindNetworkStats(NetworkStatsHistory stats) { + mStats = stats; + + mPathStroke.reset(); + mPathFill.reset(); + invalidate(); + } + + /** + * Set the range to paint with {@link #mPaintFill}, leaving the remaining + * area to be painted with {@link #mPaintFillSecondary}. + */ + public void setPrimaryRange(long left, long right) { + mPrimaryLeft = left; + mPrimaryRight = right; + invalidate(); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + generatePath(); + } + + /** + * Erase any existing {@link Path} and generate series outline based on + * currently bound {@link NetworkStatsHistory} data. + */ + public void generatePath() { + if (LOGD) Log.d(TAG, "generatePath()"); + + mPathStroke.reset(); + mPathFill.reset(); + + // bail when not enough stats to render + if (mStats == null || mStats.size() < 2) return; + + final int width = getWidth(); + final int height = getHeight(); + + boolean started = false; + float firstX = 0; + float lastX = 0; + float lastY = 0; + + // TODO: count fractional data from first bucket crossing start; + // currently it only accepts first full bucket. + + long totalData = 0; + + NetworkStatsHistory.Entry entry = null; + for (int i = 0; i < mStats.size(); i++) { + entry = mStats.getValues(i, entry); + + final float x = mHoriz.convertToPoint(entry.bucketStart); + final float y = mVert.convertToPoint(totalData); + + // skip until we find first stats on screen + if (i > 0 && !started && x > 0) { + mPathStroke.moveTo(lastX, lastY); + mPathFill.moveTo(lastX, lastY); + started = true; + firstX = x; + } + + if (started) { + mPathStroke.lineTo(x, y); + mPathFill.lineTo(x, y); + totalData += entry.rxBytes + entry.txBytes; + } + + // skip if beyond view + if (x > width) break; + + lastX = x; + lastY = y; + } + + if (LOGD) { + final RectF bounds = new RectF(); + mPathFill.computeBounds(bounds, true); + Log.d(TAG, "onLayout() rendered with bounds=" + bounds.toString() + " and totalData=" + + totalData); + } + + // drop to bottom of graph from current location + mPathFill.lineTo(lastX, height); + mPathFill.lineTo(firstX, height); + } + + @Override + protected void onDraw(Canvas canvas) { + int save; + + final float primaryLeftPoint = mHoriz.convertToPoint(mPrimaryLeft); + final float primaryRightPoint = mHoriz.convertToPoint(mPrimaryRight); + + save = canvas.save(); + canvas.clipRect(0, 0, primaryLeftPoint, getHeight()); + canvas.drawPath(mPathFill, mPaintFillSecondary); + canvas.restoreToCount(save); + + save = canvas.save(); + canvas.clipRect(primaryRightPoint, 0, getWidth(), getHeight()); + canvas.drawPath(mPathFill, mPaintFillSecondary); + canvas.restoreToCount(save); + + save = canvas.save(); + canvas.clipRect(primaryLeftPoint, 0, primaryRightPoint, getHeight()); + canvas.drawPath(mPathFill, mPaintFill); + canvas.drawPath(mPathStroke, mPaintStroke); + canvas.restoreToCount(save); + + } +} diff --git a/src/com/android/settings/widget/ChartSweepView.java b/src/com/android/settings/widget/ChartSweepView.java new file mode 100644 index 0000000..4e37657 --- /dev/null +++ b/src/com/android/settings/widget/ChartSweepView.java @@ -0,0 +1,437 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settings.widget; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Point; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.text.DynamicLayout; +import android.text.Layout.Alignment; +import android.text.SpannableStringBuilder; +import android.text.TextPaint; +import android.util.AttributeSet; +import android.util.MathUtils; +import android.view.MotionEvent; +import android.view.View; +import android.widget.FrameLayout; + +import com.android.settings.R; +import com.google.common.base.Preconditions; + +/** + * Sweep across a {@link ChartView} at a specific {@link ChartAxis} value, which + * a user can drag. + */ +public class ChartSweepView extends FrameLayout { + + private Drawable mSweep; + private Rect mSweepPadding = new Rect(); + private Point mSweepOffset = new Point(); + + private Rect mMargins = new Rect(); + + private int mFollowAxis; + + private int mLabelSize; + private int mLabelTemplateRes; + private int mLabelColor; + + private SpannableStringBuilder mLabelTemplate; + private DynamicLayout mLabelLayout; + + private ChartAxis mAxis; + private long mValue; + + private ChartSweepView mClampAfter; + private ChartSweepView mClampBefore; + + public static final int HORIZONTAL = 0; + public static final int VERTICAL = 1; + + public interface OnSweepListener { + public void onSweep(ChartSweepView sweep, boolean sweepDone); + } + + private OnSweepListener mListener; + private MotionEvent mTracking; + + public ChartSweepView(Context context) { + this(context, null, 0); + } + + public ChartSweepView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public ChartSweepView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + + final TypedArray a = context.obtainStyledAttributes( + attrs, R.styleable.ChartSweepView, defStyle, 0); + + setSweepDrawable(a.getDrawable(R.styleable.ChartSweepView_sweepDrawable)); + setFollowAxis(a.getInt(R.styleable.ChartSweepView_followAxis, -1)); + + setLabelSize(a.getDimensionPixelSize(R.styleable.ChartSweepView_labelSize, 0)); + setLabelTemplate(a.getResourceId(R.styleable.ChartSweepView_labelTemplate, 0)); + setLabelColor(a.getColor(R.styleable.ChartSweepView_labelColor, Color.BLUE)); + + a.recycle(); + + setClipToPadding(false); + setClipChildren(false); + setWillNotDraw(false); + } + + void init(ChartAxis axis) { + mAxis = Preconditions.checkNotNull(axis, "missing axis"); + } + + public int getFollowAxis() { + return mFollowAxis; + } + + public Rect getMargins() { + return mMargins; + } + + /** + * Return the number of pixels that the "target" area is inset from the + * {@link View} edge, along the current {@link #setFollowAxis(int)}. + */ + private float getTargetInset() { + if (mFollowAxis == VERTICAL) { + final float targetHeight = mSweep.getIntrinsicHeight() - mSweepPadding.top + - mSweepPadding.bottom; + return mSweepPadding.top + (targetHeight / 2); + } else { + final float targetWidth = mSweep.getIntrinsicWidth() - mSweepPadding.left + - mSweepPadding.right; + return mSweepPadding.left + (targetWidth / 2); + } + } + + public void addOnSweepListener(OnSweepListener listener) { + mListener = listener; + } + + private void dispatchOnSweep(boolean sweepDone) { + if (mListener != null) { + mListener.onSweep(this, sweepDone); + } + } + + @Override + public void setEnabled(boolean enabled) { + super.setEnabled(enabled); + requestLayout(); + } + + public void setSweepDrawable(Drawable sweep) { + if (mSweep != null) { + mSweep.setCallback(null); + unscheduleDrawable(mSweep); + } + + if (sweep != null) { + sweep.setCallback(this); + if (sweep.isStateful()) { + sweep.setState(getDrawableState()); + } + sweep.setVisible(getVisibility() == VISIBLE, false); + mSweep = sweep; + sweep.getPadding(mSweepPadding); + } else { + mSweep = null; + } + + invalidate(); + } + + public void setFollowAxis(int followAxis) { + mFollowAxis = followAxis; + } + + public void setLabelSize(int size) { + mLabelSize = size; + invalidateLabelTemplate(); + } + + public void setLabelTemplate(int resId) { + mLabelTemplateRes = resId; + invalidateLabelTemplate(); + } + + public void setLabelColor(int color) { + mLabelColor = color; + invalidateLabelTemplate(); + } + + private void invalidateLabelTemplate() { + if (mLabelTemplateRes != 0) { + final CharSequence template = getResources().getText(mLabelTemplateRes); + + final TextPaint paint = new TextPaint(Paint.ANTI_ALIAS_FLAG); + paint.density = getResources().getDisplayMetrics().density; + paint.setCompatibilityScaling(getResources().getCompatibilityInfo().applicationScale); + paint.setColor(mLabelColor); + + mLabelTemplate = new SpannableStringBuilder(template); + mLabelLayout = new DynamicLayout( + mLabelTemplate, paint, mLabelSize, Alignment.ALIGN_RIGHT, 1f, 0f, false); + invalidateLabel(); + + } else { + mLabelTemplate = null; + mLabelLayout = null; + } + + invalidate(); + requestLayout(); + } + + private void invalidateLabel() { + if (mLabelTemplate != null && mAxis != null) { + mAxis.buildLabel(getResources(), mLabelTemplate, mValue); + invalidate(); + } + } + + @Override + public void jumpDrawablesToCurrentState() { + super.jumpDrawablesToCurrentState(); + if (mSweep != null) { + mSweep.jumpToCurrentState(); + } + } + + @Override + public void setVisibility(int visibility) { + super.setVisibility(visibility); + if (mSweep != null) { + mSweep.setVisible(visibility == VISIBLE, false); + } + } + + @Override + protected boolean verifyDrawable(Drawable who) { + return who == mSweep || super.verifyDrawable(who); + } + + public ChartAxis getAxis() { + return mAxis; + } + + public void setValue(long value) { + mValue = value; + invalidateLabel(); + } + + public long getValue() { + return mValue; + } + + public float getPoint() { + if (isEnabled()) { + return mAxis.convertToPoint(mValue); + } else { + // when disabled, show along top edge + return 0; + } + } + + public void setClampAfter(ChartSweepView clampAfter) { + mClampAfter = clampAfter; + } + + public void setClampBefore(ChartSweepView clampBefore) { + mClampBefore = clampBefore; + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + if (!isEnabled()) return false; + + final View parent = (View) getParent(); + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: { + + // only start tracking when in sweet spot + final boolean accept; + if (mFollowAxis == VERTICAL) { + accept = event.getX() > getWidth() - (mSweepPadding.right * 2); + } else { + accept = event.getY() > getHeight() - (mSweepPadding.bottom * 2); + } + + if (accept) { + mTracking = event.copy(); + return true; + } else { + return false; + } + } + case MotionEvent.ACTION_MOVE: { + getParent().requestDisallowInterceptTouchEvent(true); + + // content area of parent + final Rect parentContent = new Rect(parent.getPaddingLeft(), parent.getPaddingTop(), + parent.getWidth() - parent.getPaddingRight(), + parent.getHeight() - parent.getPaddingBottom()); + final Rect clampRect = computeClampRect(parentContent); + + if (mFollowAxis == VERTICAL) { + final float currentTargetY = getTop() - mMargins.top; + final float requestedTargetY = currentTargetY + + (event.getRawY() - mTracking.getRawY()); + final float clampedTargetY = MathUtils.constrain( + requestedTargetY, clampRect.top, clampRect.bottom); + setTranslationY(clampedTargetY - currentTargetY); + + setValue(mAxis.convertToValue(clampedTargetY - parentContent.top)); + } else { + final float currentTargetX = getLeft() - mMargins.left; + final float requestedTargetX = currentTargetX + + (event.getRawX() - mTracking.getRawX()); + final float clampedTargetX = MathUtils.constrain( + requestedTargetX, clampRect.left, clampRect.right); + setTranslationX(clampedTargetX - currentTargetX); + + setValue(mAxis.convertToValue(clampedTargetX - parentContent.left)); + } + + dispatchOnSweep(false); + return true; + } + case MotionEvent.ACTION_UP: { + mTracking = null; + dispatchOnSweep(true); + setTranslationX(0); + setTranslationY(0); + requestLayout(); + return true; + } + default: { + return false; + } + } + } + + /** + * Compute {@link Rect} in {@link #getParent()} coordinates that we should + * be clamped inside of, usually from {@link #setClampAfter(ChartSweepView)} + * style rules. + */ + private Rect computeClampRect(Rect parentContent) { + final Rect clampRect = new Rect(parentContent); + + final ChartSweepView after = mClampAfter; + final ChartSweepView before = mClampBefore; + + if (mFollowAxis == VERTICAL) { + if (after != null) { + clampRect.top += after.getPoint(); + } + if (before != null) { + clampRect.bottom -= clampRect.height() - before.getPoint(); + } + } else { + if (after != null) { + clampRect.left += after.getPoint(); + } + if (before != null) { + clampRect.right -= clampRect.width() - before.getPoint(); + } + } + return clampRect; + } + + @Override + protected void drawableStateChanged() { + super.drawableStateChanged(); + if (mSweep.isStateful()) { + mSweep.setState(getDrawableState()); + } + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + + // TODO: handle vertical labels + if (isEnabled() && mLabelLayout != null) { + final int sweepHeight = mSweep.getIntrinsicHeight(); + final int templateHeight = mLabelLayout.getHeight(); + + mSweepOffset.x = 0; + mSweepOffset.y = (int) ((templateHeight / 2) - getTargetInset()); + setMeasuredDimension(mSweep.getIntrinsicWidth(), Math.max(sweepHeight, templateHeight)); + + } else { + mSweepOffset.x = 0; + mSweepOffset.y = 0; + setMeasuredDimension(mSweep.getIntrinsicWidth(), mSweep.getIntrinsicHeight()); + } + + if (mFollowAxis == VERTICAL) { + final int targetHeight = mSweep.getIntrinsicHeight() - mSweepPadding.top + - mSweepPadding.bottom; + mMargins.top = -(mSweepPadding.top + (targetHeight / 2)); + mMargins.bottom = 0; + mMargins.left = -mSweepPadding.left; + mMargins.right = mSweepPadding.right; + } else { + final int targetWidth = mSweep.getIntrinsicWidth() - mSweepPadding.left + - mSweepPadding.right; + mMargins.left = -(mSweepPadding.left + (targetWidth / 2)); + mMargins.right = 0; + mMargins.top = -mSweepPadding.top; + mMargins.bottom = mSweepPadding.bottom; + } + + mMargins.offset(-mSweepOffset.x, -mSweepOffset.y); + } + + @Override + protected void onDraw(Canvas canvas) { + final int width = getWidth(); + final int height = getHeight(); + + final int labelSize; + if (isEnabled() && mLabelLayout != null) { + mLabelLayout.draw(canvas); + labelSize = mLabelSize; + } else { + labelSize = 0; + } + + if (mFollowAxis == VERTICAL) { + mSweep.setBounds(labelSize, mSweepOffset.y, width, + mSweepOffset.y + mSweep.getIntrinsicHeight()); + } else { + mSweep.setBounds(mSweepOffset.x, labelSize, + mSweepOffset.x + mSweep.getIntrinsicWidth(), height); + } + + mSweep.draw(canvas); + } + +} diff --git a/src/com/android/settings/widget/ChartView.java b/src/com/android/settings/widget/ChartView.java new file mode 100644 index 0000000..a5b8b09 --- /dev/null +++ b/src/com/android/settings/widget/ChartView.java @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settings.widget; + +import static com.google.common.base.Preconditions.checkNotNull; + +import android.content.Context; +import android.graphics.Rect; +import android.util.AttributeSet; +import android.view.Gravity; +import android.view.View; +import android.widget.FrameLayout; + +/** + * Container for two-dimensional chart, drawn with a combination of + * {@link ChartGridView}, {@link ChartNetworkSeriesView} and {@link ChartSweepView} + * children. The entire chart uses {@link ChartAxis} to map between raw values + * and screen coordinates. + */ +public class ChartView extends FrameLayout { + private static final String TAG = "ChartView"; + + // TODO: extend something that supports two-dimensional scrolling + + private static final int SWEEP_GRAVITY = Gravity.TOP | Gravity.LEFT; + + ChartAxis mHoriz; + ChartAxis mVert; + + private Rect mContent = new Rect(); + + public ChartView(Context context) { + this(context, null, 0); + } + + public ChartView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public ChartView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + + setClipToPadding(false); + setClipChildren(false); + } + + void init(ChartAxis horiz, ChartAxis vert) { + mHoriz = checkNotNull(horiz, "missing horiz"); + mVert = checkNotNull(vert, "missing vert"); + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + mContent.set(getPaddingLeft(), getPaddingTop(), r - l - getPaddingRight(), + b - t - getPaddingBottom()); + final int width = mContent.width(); + final int height = mContent.height(); + + // no scrolling yet, so tell dimensions to fill exactly + mHoriz.setSize(width); + mVert.setSize(height); + + final Rect parentRect = new Rect(); + final Rect childRect = new Rect(); + + for (int i = 0; i < getChildCount(); i++) { + final View child = getChildAt(i); + final LayoutParams params = (LayoutParams) child.getLayoutParams(); + + parentRect.set(mContent); + + if (child instanceof ChartNetworkSeriesView || child instanceof ChartGridView) { + // series are always laid out to fill entire graph area + // TODO: handle scrolling for series larger than content area + Gravity.apply(params.gravity, width, height, parentRect, childRect); + child.layout(childRect.left, childRect.top, childRect.right, childRect.bottom); + + } else if (child instanceof ChartSweepView) { + // sweep is always placed along specific dimension + final ChartSweepView sweep = (ChartSweepView) child; + final Rect sweepMargins = sweep.getMargins(); + + if (sweep.getFollowAxis() == ChartSweepView.VERTICAL) { + parentRect.top += sweepMargins.top + (int) sweep.getPoint(); + parentRect.bottom = parentRect.top; + parentRect.left += sweepMargins.left; + parentRect.right += sweepMargins.right; + Gravity.apply(SWEEP_GRAVITY, parentRect.width(), child.getMeasuredHeight(), + parentRect, childRect); + + } else { + parentRect.left += sweepMargins.left + (int) sweep.getPoint(); + parentRect.right = parentRect.left; + parentRect.top += sweepMargins.top; + parentRect.bottom += sweepMargins.bottom; + Gravity.apply(SWEEP_GRAVITY, child.getMeasuredWidth(), parentRect.height(), + parentRect, childRect); + } + } + + child.layout(childRect.left, childRect.top, childRect.right, childRect.bottom); + } + } + +} diff --git a/src/com/android/settings/widget/DataUsageChartView.java b/src/com/android/settings/widget/DataUsageChartView.java new file mode 100644 index 0000000..affede0 --- /dev/null +++ b/src/com/android/settings/widget/DataUsageChartView.java @@ -0,0 +1,406 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settings.widget; + +import android.content.Context; +import android.content.res.Resources; +import android.net.NetworkPolicy; +import android.net.NetworkStatsHistory; +import android.text.Spannable; +import android.text.SpannableStringBuilder; +import android.text.TextUtils; +import android.text.format.DateUtils; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; + +import com.android.settings.R; +import com.android.settings.widget.ChartSweepView.OnSweepListener; + +/** + * Specific {@link ChartView} that displays {@link ChartNetworkSeriesView} along + * with {@link ChartSweepView} for inspection ranges and warning/limits. + */ +public class DataUsageChartView extends ChartView { + + private static final long KB_IN_BYTES = 1024; + private static final long MB_IN_BYTES = KB_IN_BYTES * 1024; + private static final long GB_IN_BYTES = MB_IN_BYTES * 1024; + + // TODO: enforce that sweeps cant cross each other + + private ChartGridView mGrid; + private ChartNetworkSeriesView mSeries; + private ChartNetworkSeriesView mDetailSeries; + + private ChartSweepView mSweepLeft; + private ChartSweepView mSweepRight; + private ChartSweepView mSweepWarning; + private ChartSweepView mSweepLimit; + + public interface DataUsageChartListener { + public void onInspectRangeChanged(); + public void onWarningChanged(); + public void onLimitChanged(); + } + + private DataUsageChartListener mListener; + + public DataUsageChartView(Context context) { + this(context, null, 0); + } + + public DataUsageChartView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public DataUsageChartView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + init(new TimeAxis(), new InvertedChartAxis(new DataAxis())); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + + mGrid = (ChartGridView) findViewById(R.id.grid); + mSeries = (ChartNetworkSeriesView) findViewById(R.id.series); + mDetailSeries = (ChartNetworkSeriesView) findViewById(R.id.detail_series); + mDetailSeries.setVisibility(View.GONE); + + mSweepLeft = (ChartSweepView) findViewById(R.id.sweep_left); + mSweepRight = (ChartSweepView) findViewById(R.id.sweep_right); + mSweepLimit = (ChartSweepView) findViewById(R.id.sweep_limit); + mSweepWarning = (ChartSweepView) findViewById(R.id.sweep_warning); + + // prevent sweeps from crossing each other + mSweepLeft.setClampBefore(mSweepRight); + mSweepRight.setClampAfter(mSweepLeft); + mSweepLimit.setClampBefore(mSweepWarning); + mSweepWarning.setClampAfter(mSweepLimit); + + mSweepLeft.addOnSweepListener(mSweepListener); + mSweepRight.addOnSweepListener(mSweepListener); + mSweepWarning.addOnSweepListener(mWarningListener); + mSweepLimit.addOnSweepListener(mLimitListener); + + // tell everyone about our axis + mGrid.init(mHoriz, mVert); + mSeries.init(mHoriz, mVert); + mDetailSeries.init(mHoriz, mVert); + mSweepLeft.init(mHoriz); + mSweepRight.init(mHoriz); + mSweepWarning.init(mVert); + mSweepLimit.init(mVert); + + setActivated(false); + } + + public void setListener(DataUsageChartListener listener) { + mListener = listener; + } + + public void bindNetworkStats(NetworkStatsHistory stats) { + mSeries.bindNetworkStats(stats); + updatePrimaryRange(); + requestLayout(); + } + + public void bindDetailNetworkStats(NetworkStatsHistory stats) { + mDetailSeries.bindNetworkStats(stats); + mDetailSeries.setVisibility(stats != null ? View.VISIBLE : View.GONE); + updatePrimaryRange(); + requestLayout(); + } + + public void bindNetworkPolicy(NetworkPolicy policy) { + if (policy == null) { + mSweepLimit.setVisibility(View.INVISIBLE); + mSweepWarning.setVisibility(View.INVISIBLE); + return; + } + + if (policy.limitBytes != NetworkPolicy.LIMIT_DISABLED) { + mSweepLimit.setVisibility(View.VISIBLE); + mSweepLimit.setValue(policy.limitBytes); + mSweepLimit.setEnabled(true); + } else { + mSweepLimit.setVisibility(View.VISIBLE); + mSweepLimit.setEnabled(false); + } + + if (policy.warningBytes != NetworkPolicy.WARNING_DISABLED) { + mSweepWarning.setVisibility(View.VISIBLE); + mSweepWarning.setValue(policy.warningBytes); + } else { + mSweepWarning.setVisibility(View.INVISIBLE); + } + + requestLayout(); + } + + private OnSweepListener mSweepListener = new OnSweepListener() { + public void onSweep(ChartSweepView sweep, boolean sweepDone) { + updatePrimaryRange(); + + // update detail list only when done sweeping + if (sweepDone && mListener != null) { + mListener.onInspectRangeChanged(); + } + } + }; + + private OnSweepListener mWarningListener = new OnSweepListener() { + public void onSweep(ChartSweepView sweep, boolean sweepDone) { + if (sweepDone && mListener != null) { + mListener.onWarningChanged(); + } + } + }; + + private OnSweepListener mLimitListener = new OnSweepListener() { + public void onSweep(ChartSweepView sweep, boolean sweepDone) { + if (sweepDone && mListener != null) { + mListener.onLimitChanged(); + } + } + }; + + @Override + public boolean onTouchEvent(MotionEvent event) { + if (isActivated()) return false; + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: { + return true; + } + case MotionEvent.ACTION_UP: { + setActivated(true); + return true; + } + default: { + return false; + } + } + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + if (!isActivated()) { + return true; + } else { + return super.onInterceptTouchEvent(ev); + } + } + + public long getInspectStart() { + return mSweepLeft.getValue(); + } + + public long getInspectEnd() { + return mSweepRight.getValue(); + } + + public long getWarningBytes() { + return mSweepWarning.getValue(); + } + + public long getLimitBytes() { + return mSweepLimit.getValue(); + } + + /** + * Set the exact time range that should be displayed, updating how + * {@link ChartNetworkSeriesView} paints. Moves inspection ranges to be the + * last "week" of available data, without triggering listener events. + */ + public void setVisibleRange(long start, long end, long dataBoundary) { + mHoriz.setBounds(start, end); + + // default sweeps to last week of data + final long halfRange = (end + start) / 2; + final long sweepMax = Math.min(end, dataBoundary); + final long sweepMin = Math.max(start, (sweepMax - DateUtils.WEEK_IN_MILLIS)); + + mSweepLeft.setValue(sweepMin); + mSweepRight.setValue(sweepMax); + updatePrimaryRange(); + + requestLayout(); + mSeries.generatePath(); + mSeries.invalidate(); + } + + private void updatePrimaryRange() { + final long left = mSweepLeft.getValue(); + final long right = mSweepRight.getValue(); + + // prefer showing primary range on detail series, when available + if (mDetailSeries.getVisibility() == View.VISIBLE) { + mDetailSeries.setPrimaryRange(left, right); + mSeries.setPrimaryRange(0, 0); + } else { + mSeries.setPrimaryRange(left, right); + } + } + + public static class TimeAxis implements ChartAxis { + private static final long TICK_INTERVAL = DateUtils.DAY_IN_MILLIS * 7; + + private long mMin; + private long mMax; + private float mSize; + + public TimeAxis() { + final long currentTime = System.currentTimeMillis(); + setBounds(currentTime - DateUtils.DAY_IN_MILLIS * 30, currentTime); + } + + /** {@inheritDoc} */ + public void setBounds(long min, long max) { + mMin = min; + mMax = max; + } + + /** {@inheritDoc} */ + public void setSize(float size) { + this.mSize = size; + } + + /** {@inheritDoc} */ + public float convertToPoint(long value) { + return (mSize * (value - mMin)) / (mMax - mMin); + } + + /** {@inheritDoc} */ + public long convertToValue(float point) { + return (long) (mMin + ((point * (mMax - mMin)) / mSize)); + } + + /** {@inheritDoc} */ + public void buildLabel(Resources res, SpannableStringBuilder builder, long value) { + // TODO: convert to better string + builder.replace(0, builder.length(), Long.toString(value)); + } + + /** {@inheritDoc} */ + public float[] getTickPoints() { + // tick mark for every week + final int tickCount = (int) ((mMax - mMin) / TICK_INTERVAL); + final float[] tickPoints = new float[tickCount]; + for (int i = 0; i < tickCount; i++) { + tickPoints[i] = convertToPoint(mMax - (TICK_INTERVAL * i)); + } + return tickPoints; + } + } + + public static class DataAxis implements ChartAxis { + private long mMin; + private long mMax; + private float mSize; + + public DataAxis() { + // TODO: adapt ranges to show when history >5GB, and handle 4G + // interfaces with higher limits. + setBounds(0, 5 * GB_IN_BYTES); + } + + /** {@inheritDoc} */ + public void setBounds(long min, long max) { + mMin = min; + mMax = max; + } + + /** {@inheritDoc} */ + public void setSize(float size) { + mSize = size; + } + + /** {@inheritDoc} */ + public float convertToPoint(long value) { + // TODO: this assumes range of [0,5]GB + final double fraction = Math.pow( + 10, 0.36884343106175160321 * Math.log10(value) + -3.62828151137812282556); + return (float) fraction * mSize; + } + + /** {@inheritDoc} */ + public long convertToValue(float point) { + final double y = point / mSize; + // TODO: this assumes range of [0,5]GB + final double fraction = 6.869341163271789302 * Math.pow(10, 9) + * Math.pow(y, 2.71117746931646030774); + return (long) fraction; + } + + private static final Object sSpanSize = new Object(); + private static final Object sSpanUnit = new Object(); + + /** {@inheritDoc} */ + public void buildLabel(Resources res, SpannableStringBuilder builder, long value) { + + float result = value; + final CharSequence unit; + if (result <= 100 * MB_IN_BYTES) { + unit = res.getText(com.android.internal.R.string.megabyteShort); + result /= MB_IN_BYTES; + } else { + unit = res.getText(com.android.internal.R.string.gigabyteShort); + result /= GB_IN_BYTES; + } + + final CharSequence size; + if (result < 10) { + size = String.format("%.1f", result); + } else { + size = String.format("%.0f", result); + } + + final int[] sizeBounds = findOrCreateSpan(builder, sSpanSize, "^1"); + builder.replace(sizeBounds[0], sizeBounds[1], size); + final int[] unitBounds = findOrCreateSpan(builder, sSpanUnit, "^2"); + builder.replace(unitBounds[0], unitBounds[1], unit); + } + + /** {@inheritDoc} */ + public float[] getTickPoints() { + final float[] tickPoints = new float[16]; + + final long jump = ((mMax - mMin) / tickPoints.length); + long value = mMin; + for (int i = 0; i < tickPoints.length; i++) { + tickPoints[i] = convertToPoint(value); + value += jump; + } + + return tickPoints; + } + } + + private static int[] findOrCreateSpan( + SpannableStringBuilder builder, Object key, CharSequence bootstrap) { + int start = builder.getSpanStart(key); + int end = builder.getSpanEnd(key); + if (start == -1) { + start = TextUtils.indexOf(builder, bootstrap); + end = start + bootstrap.length(); + builder.setSpan(key, start, end, Spannable.SPAN_INCLUSIVE_INCLUSIVE); + } + return new int[] { start, end }; + } + +} diff --git a/src/com/android/settings/widget/InvertedChartAxis.java b/src/com/android/settings/widget/InvertedChartAxis.java new file mode 100644 index 0000000..e589da9 --- /dev/null +++ b/src/com/android/settings/widget/InvertedChartAxis.java @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settings.widget; + +import android.content.res.Resources; +import android.text.SpannableStringBuilder; + +/** + * Utility to invert another {@link ChartAxis}. + */ +public class InvertedChartAxis implements ChartAxis { + private final ChartAxis mWrapped; + private float mSize; + + public InvertedChartAxis(ChartAxis wrapped) { + mWrapped = wrapped; + } + + /** {@inheritDoc} */ + public void setBounds(long min, long max) { + mWrapped.setBounds(min, max); + } + + /** {@inheritDoc} */ + public void setSize(float size) { + mSize = size; + mWrapped.setSize(size); + } + + /** {@inheritDoc} */ + public float convertToPoint(long value) { + return mSize - mWrapped.convertToPoint(value); + } + + /** {@inheritDoc} */ + public long convertToValue(float point) { + return mWrapped.convertToValue(mSize - point); + } + + /** {@inheritDoc} */ + public void buildLabel(Resources res, SpannableStringBuilder builder, long value) { + mWrapped.buildLabel(res, builder, value); + } + + /** {@inheritDoc} */ + public float[] getTickPoints() { + final float[] points = mWrapped.getTickPoints(); + for (int i = 0; i < points.length; i++) { + points[i] = mSize - points[i]; + } + return points; + } +} |