diff options
Diffstat (limited to 'WordPress/src/main/java/org/wordpress/android/ui/stats')
79 files changed, 14585 insertions, 0 deletions
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/FollowHelper.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/FollowHelper.java new file mode 100644 index 000000000..cb6c20cd5 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/FollowHelper.java @@ -0,0 +1,118 @@ +package org.wordpress.android.ui.stats; + +import android.app.Activity; +import android.view.MenuItem; +import android.view.View; +import android.widget.PopupMenu; + +import com.android.volley.VolleyError; +import com.wordpress.rest.RestRequest; + +import org.json.JSONException; +import org.json.JSONObject; +import org.wordpress.android.R; +import org.wordpress.android.WordPress; +import org.wordpress.android.networking.RestClientUtils; +import org.wordpress.android.ui.stats.models.FollowDataModel; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.ToastUtils; + +import java.lang.ref.WeakReference; + +class FollowHelper { + + private final WeakReference<Activity> mActivityRef; + + public FollowHelper(Activity activity) { + mActivityRef = new WeakReference<>(activity); + } + + + public void showPopup(View anchor, final FollowDataModel followData) { + if (mActivityRef.get() == null || mActivityRef.get().isFinishing()) { + return; + } + + final String workingText = followData.getFollowingText(); + final String followText = followData.getFollowText(); + final String unfollowText = followData.getFollowingHoverText(); + + final PopupMenu popup = new PopupMenu(mActivityRef.get(), anchor); + final MenuItem menuItem; + + if (followData.isRestCallInProgress) { + menuItem = popup.getMenu().add(workingText); + } else { + menuItem = popup.getMenu().add(followData.isFollowing() ? unfollowText : followText); + } + + menuItem.setOnMenuItemClickListener(new MenuItem.OnMenuItemClickListener() { + @Override + public boolean onMenuItemClick(MenuItem item) { + item.setTitle(workingText); + item.setOnMenuItemClickListener(null); + + final RestClientUtils restClientUtils = WordPress.getRestClientUtils(); + final String restPath; + if (!followData.isFollowing()) { + restPath = String.format("/sites/%s/follows/new", followData.getSiteID()); + } else { + restPath = String.format("/sites/%s/follows/mine/delete", followData.getSiteID()); + } + + followData.isRestCallInProgress = true; + FollowRestListener vListener = new FollowRestListener(mActivityRef.get(), followData); + restClientUtils.post(restPath, vListener, vListener); + AppLog.d(AppLog.T.STATS, "Enqueuing the following REST request " + restPath); + return true; + } + }); + + popup.show(); + + } + + + private class FollowRestListener implements RestRequest.Listener, RestRequest.ErrorListener { + private final WeakReference<Activity> mActivityRef; + private final FollowDataModel mFollowData; + + public FollowRestListener(Activity activity, final FollowDataModel followData) { + this.mActivityRef = new WeakReference<>(activity); + this.mFollowData = followData; + } + + @Override + public void onResponse(final JSONObject response) { + if (mActivityRef.get() == null || mActivityRef.get().isFinishing()) { + return; + } + + mFollowData.isRestCallInProgress = false; + if (response!= null) { + try { + boolean isFollowing = response.getBoolean("is_following"); + mFollowData.setIsFollowing(isFollowing); + } catch (JSONException e) { + e.printStackTrace(); + } + } + } + + @Override + public void onErrorResponse(final VolleyError volleyError) { + if (volleyError != null) { + AppLog.e(AppLog.T.STATS, "Error while following a blog " + + volleyError.getMessage(), volleyError); + } + if (mActivityRef.get() == null || mActivityRef.get().isFinishing()) { + return; + } + + mFollowData.isRestCallInProgress = false; + ToastUtils.showToast(mActivityRef.get(), + mActivityRef.get().getString(R.string.reader_toast_err_follow_blog), + ToastUtils.Duration.LONG); + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/NestedScrollViewExt.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/NestedScrollViewExt.java new file mode 100644 index 000000000..f601cdb38 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/NestedScrollViewExt.java @@ -0,0 +1,38 @@ +package org.wordpress.android.ui.stats; + +import android.content.Context; +import android.support.v4.widget.NestedScrollView; +import android.util.AttributeSet; + +public class NestedScrollViewExt extends NestedScrollView { + private ScrollViewListener mScrollViewListener = null; + public NestedScrollViewExt(Context context) { + super(context); + } + + public NestedScrollViewExt(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + public NestedScrollViewExt(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public void setScrollViewListener(ScrollViewListener scrollViewListener) { + this.mScrollViewListener = scrollViewListener; + } + + @Override + protected void onScrollChanged(int l, int t, int oldl, int oldt) { + super.onScrollChanged(l, t, oldl, oldt); + if (mScrollViewListener != null) { + mScrollViewListener.onScrollChanged(this, l, t, oldl, oldt); + } + } + + public interface ScrollViewListener { + void onScrollChanged(NestedScrollViewExt scrollView, + int x, int y, int oldx, int oldy); + } +} + diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/ReferrerSpamHelper.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/ReferrerSpamHelper.java new file mode 100644 index 000000000..c8738cd9b --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/ReferrerSpamHelper.java @@ -0,0 +1,159 @@ +package org.wordpress.android.ui.stats; + +import android.app.Activity; +import android.text.TextUtils; +import android.view.MenuItem; +import android.view.View; +import android.widget.PopupMenu; + +import com.android.volley.VolleyError; +import com.wordpress.rest.RestRequest; + +import org.json.JSONObject; +import org.wordpress.android.R; +import org.wordpress.android.WordPress; +import org.wordpress.android.networking.RestClientUtils; +import org.wordpress.android.ui.stats.datasets.StatsTable; +import org.wordpress.android.ui.stats.models.ReferrerGroupModel; +import org.wordpress.android.ui.stats.service.StatsService; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.ToastUtils; +import org.wordpress.android.util.UrlUtils; + +import java.lang.ref.WeakReference; + +class ReferrerSpamHelper { + + private final WeakReference<Activity> mActivityRef; + + public ReferrerSpamHelper(Activity activity) { + mActivityRef = new WeakReference<>(activity); + } + + // return the domain of the passed ReferrerGroupModel or null. + private static String getDomain(ReferrerGroupModel group) { + // Use the URL value given in the JSON response, or use the groupID that doesn't contain the schema. + final String spamDomain = group.getUrl() != null ? group.getUrl() : "http://" + group.getGroupId(); + return UrlUtils.isValidUrlAndHostNotNull(spamDomain) ? UrlUtils.getHost(spamDomain) : null; + } + + public static boolean isSpamActionAvailable(ReferrerGroupModel group) { + String domain = getDomain(group); + return !TextUtils.isEmpty(domain) && !domain.equals("wordpress.com"); + } + + public void showPopup(View anchor, final ReferrerGroupModel referrerGroup) { + if (mActivityRef.get() == null || mActivityRef.get().isFinishing()) { + return; + } + + final PopupMenu popup = new PopupMenu(mActivityRef.get(), anchor); + final MenuItem menuItem; + + if (referrerGroup.isRestCallInProgress) { + menuItem = popup.getMenu().add( + referrerGroup.isMarkedAsSpam ? + mActivityRef.get().getString(R.string.stats_referrers_marking_not_spam) : + mActivityRef.get().getString(R.string.stats_referrers_marking_spam) + ); + } else { + menuItem = popup.getMenu().add( + referrerGroup.isMarkedAsSpam ? + mActivityRef.get().getString(R.string.stats_referrers_unspam) : + mActivityRef.get().getString(R.string.stats_referrers_spam) + ); + } + + menuItem.setOnMenuItemClickListener(new MenuItem.OnMenuItemClickListener() { + @Override + public boolean onMenuItemClick(MenuItem item) { + item.setTitle( + referrerGroup.isMarkedAsSpam ? + mActivityRef.get().getString(R.string.stats_referrers_marking_not_spam) : + mActivityRef.get().getString(R.string.stats_referrers_marking_spam) + ); + item.setOnMenuItemClickListener(null); + + final RestClientUtils restClientUtils = WordPress.getRestClientUtilsV1_1(); + final String restPath; + final boolean isMarkingAsSpamInProgress; + if (referrerGroup.isMarkedAsSpam) { + restPath = String.format("/sites/%s/stats/referrers/spam/delete/?domain=%s", referrerGroup.getBlogId(), getDomain(referrerGroup)); + isMarkingAsSpamInProgress = false; + } else { + restPath = String.format("/sites/%s/stats/referrers/spam/new/?domain=%s", referrerGroup.getBlogId(), getDomain(referrerGroup)); + isMarkingAsSpamInProgress = true; + } + + referrerGroup.isRestCallInProgress = true; + ReferrerSpamRestListener vListener = new ReferrerSpamRestListener(mActivityRef.get(), referrerGroup, isMarkingAsSpamInProgress); + restClientUtils.post(restPath, vListener, vListener); + AppLog.d(AppLog.T.STATS, "Enqueuing the following REST request " + restPath); + return true; + } + }); + + popup.show(); + } + + + private class ReferrerSpamRestListener implements RestRequest.Listener, RestRequest.ErrorListener { + private final WeakReference<Activity> mActivityRef; + private final ReferrerGroupModel mReferrerGroup; + private final boolean isMarkingAsSpamInProgress; + + public ReferrerSpamRestListener(Activity activity, final ReferrerGroupModel referrerGroup, final boolean isMarkingAsSpamInProgress) { + this.mActivityRef = new WeakReference<>(activity); + this.mReferrerGroup = referrerGroup; + this.isMarkingAsSpamInProgress = isMarkingAsSpamInProgress; + } + + @Override + public void onResponse(final JSONObject response) { + if (mActivityRef.get() == null || mActivityRef.get().isFinishing()) { + return; + } + + mReferrerGroup.isRestCallInProgress = false; + if (response!= null) { + boolean success = response.optBoolean("success"); + if (success) { + mReferrerGroup.isMarkedAsSpam = isMarkingAsSpamInProgress; + int localBlogID = StatsUtils.getLocalBlogIdFromRemoteBlogId( + Integer.parseInt(mReferrerGroup.getBlogId()) + ); + StatsTable.deleteStatsForBlog(mActivityRef.get(), localBlogID, StatsService.StatsEndpointsEnum.REFERRERS); + } else { + // It's not a success. Something went wrong on the server + String errorMessage = null; + if (response.has("error")) { + errorMessage = response.optString("message"); + } + + if (TextUtils.isEmpty(errorMessage)) { + errorMessage = mActivityRef.get().getString(R.string.stats_referrers_spam_generic_error); + } + + ToastUtils.showToast(mActivityRef.get(), errorMessage, ToastUtils.Duration.LONG); + } + } + } + + @Override + public void onErrorResponse(final VolleyError volleyError) { + if (volleyError != null) { + AppLog.e(AppLog.T.STATS, "Error while marking the referrer " + getDomain(mReferrerGroup) + " as " + + (isMarkingAsSpamInProgress ? " spam " : " unspam ") + + volleyError.getMessage(), volleyError); + } + if (mActivityRef.get() == null || mActivityRef.get().isFinishing()) { + return; + } + + mReferrerGroup.isRestCallInProgress = false; + ToastUtils.showToast(mActivityRef.get(), + mActivityRef.get().getString(R.string.stats_referrers_spam_generic_error), + ToastUtils.Duration.LONG); + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/ScrollViewExt.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/ScrollViewExt.java new file mode 100644 index 000000000..4b734adef --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/ScrollViewExt.java @@ -0,0 +1,38 @@ +package org.wordpress.android.ui.stats; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.ScrollView; + +public class ScrollViewExt extends ScrollView { + private ScrollViewListener mScrollViewListener = null; + public ScrollViewExt(Context context) { + super(context); + } + + public ScrollViewExt(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + public ScrollViewExt(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public void setScrollViewListener(ScrollViewListener scrollViewListener) { + this.mScrollViewListener = scrollViewListener; + } + + @Override + protected void onScrollChanged(int l, int t, int oldl, int oldt) { + super.onScrollChanged(l, t, oldl, oldt); + if (mScrollViewListener != null) { + mScrollViewListener.onScrollChanged(this, l, t, oldl, oldt); + } + } + + public interface ScrollViewListener { + void onScrollChanged(ScrollViewExt scrollView, + int x, int y, int oldx, int oldy); + } +} + diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/SparseBooleanArrayParcelable.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/SparseBooleanArrayParcelable.java new file mode 100644 index 000000000..1503a3631 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/SparseBooleanArrayParcelable.java @@ -0,0 +1,62 @@ +package org.wordpress.android.ui.stats; + +import android.os.Parcel; +import android.os.Parcelable; +import android.util.SparseBooleanArray; + +public class SparseBooleanArrayParcelable extends SparseBooleanArray implements Parcelable { + public static Parcelable.Creator<SparseBooleanArrayParcelable> CREATOR = new Parcelable.Creator<SparseBooleanArrayParcelable>() { + @Override + public SparseBooleanArrayParcelable createFromParcel(Parcel source) { + SparseBooleanArrayParcelable read = new SparseBooleanArrayParcelable(); + int size = source.readInt(); + + int[] keys = new int[size]; + boolean[] values = new boolean[size]; + + source.readIntArray(keys); + source.readBooleanArray(values); + + for (int i = 0; i < size; i++) { + read.put(keys[i], values[i]); + } + + return read; + } + + @Override + public SparseBooleanArrayParcelable[] newArray(int size) { + return new SparseBooleanArrayParcelable[size]; + } + }; + + public SparseBooleanArrayParcelable() { + + } + + public SparseBooleanArrayParcelable(SparseBooleanArray sparseBooleanArray) { + for (int i = 0; i < sparseBooleanArray.size(); i++) { + this.put(sparseBooleanArray.keyAt(i), sparseBooleanArray.valueAt(i)); + } + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + int[] keys = new int[size()]; + boolean[] values = new boolean[size()]; + + for (int i = 0; i < size(); i++) { + keys[i] = keyAt(i); + values[i] = valueAt(i); + } + + dest.writeInt(size()); + dest.writeIntArray(keys); + dest.writeBooleanArray(values); + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsAbstractFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsAbstractFragment.java new file mode 100644 index 000000000..a6d761b9c --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsAbstractFragment.java @@ -0,0 +1,361 @@ +package org.wordpress.android.ui.stats; + + +import android.app.Activity; +import android.app.Fragment; +import android.content.Intent; +import android.os.Bundle; + +import com.android.volley.NoConnectionError; +import com.android.volley.VolleyError; + +import org.wordpress.android.R; +import org.wordpress.android.WordPress; +import org.wordpress.android.models.AccountHelper; +import org.wordpress.android.models.Blog; +import org.wordpress.android.ui.stats.service.StatsService; +import org.wordpress.android.util.AppLog; + +import de.greenrobot.event.EventBus; + + +public abstract class StatsAbstractFragment extends Fragment { + public static final String TAG = StatsAbstractFragment.class.getSimpleName(); + + public static final String ARGS_VIEW_TYPE = "ARGS_VIEW_TYPE"; + public static final String ARGS_TIMEFRAME = "ARGS_TIMEFRAME"; + public static final String ARGS_SELECTED_DATE = "ARGS_SELECTED_DATE"; + static final String ARG_REST_RESPONSE = "ARG_REST_RESPONSE"; + static final String ARGS_IS_SINGLE_VIEW = "ARGS_IS_SINGLE_VIEW"; + + // The number of results to return for NON Paged REST endpoints. + private static final int MAX_RESULTS_REQUESTED = 100; + + private String mDate; + private StatsTimeframe mStatsTimeframe = StatsTimeframe.DAY; + + protected abstract StatsService.StatsEndpointsEnum[] sectionsToUpdate(); + protected abstract void showPlaceholderUI(); + protected abstract void updateUI(); + protected abstract void showErrorUI(String label); + + /** + * Wheter or not previous data is available. + * @return True if previous data is already available in the fragment + */ + protected abstract boolean hasDataAvailable(); + + /** + * Called in onSaveIstance. Fragments should persist data here. + * @param outState Bundle in which to place fragment saved state. + */ + protected abstract void saveStatsData(Bundle outState); + + /** + * Called in OnCreate. Fragment should restore here previous saved data. + * @param savedInstanceState If the fragment is being re-created from a previous saved state, this is the state. + */ + protected abstract void restoreStatsData(Bundle savedInstanceState); // called in onCreate + + protected StatsResourceVars mResourceVars; + + public void refreshStats() { + refreshStats(-1, null); + } + // call an update for the stats shown in the fragment + void refreshStats(int pageNumberRequested, StatsService.StatsEndpointsEnum[] sections) { + if (!isAdded()) { + return; + } + + // if no sections to update is passed to the method, default to fragment + if (sections == null) { + sections = sectionsToUpdate(); + } + + //AppLog.d(AppLog.T.STATS, this.getClass().getCanonicalName() + " > refreshStats"); + + final Blog currentBlog = WordPress.getBlog(getLocalTableBlogID()); + if (currentBlog == null) { + AppLog.w(AppLog.T.STATS, "Current blog is null. This should never happen here."); + return; + } + + final String blogId = currentBlog.getDotComBlogId(); + // Make sure the blogId is available. + if (blogId == null) { + AppLog.e(AppLog.T.STATS, "remote blogID is null: " + currentBlog.getHomeURL()); + return; + } + + // Check credentials for jetpack blogs first + if (!currentBlog.isDotcomFlag() + && !currentBlog.hasValidJetpackCredentials() && !AccountHelper.isSignedInWordPressDotCom()) { + AppLog.w(AppLog.T.STATS, "Current blog is a Jetpack blog without valid .com credentials stored"); + return; + } + + // Do not pass the array of StatsEndpointsEnum to the Service. Otherwise we get + // java.lang.RuntimeException: Unable to start service org.wordpress.android.ui.stats.service.StatsService + // with Intent { cmp=org.wordpress.android/.ui.stats.service.StatsService (has extras) }: java.lang.ClassCastException: + // java.lang.Object[] cannot be cast to org.wordpress.android.ui.stats.service.StatsService$StatsEndpointsEnum[] + // on older devices. + // We should use Enumset, or array of int. Going for the latter, since we have an array and cannot create an Enumset easily. + int[] sectionsForTheService = new int[sections.length]; + for (int i=0; i < sections.length; i++){ + sectionsForTheService[i] = sections[i].ordinal(); + } + + // start service to get stats + Intent intent = new Intent(getActivity(), StatsService.class); + intent.putExtra(StatsService.ARG_BLOG_ID, blogId); + intent.putExtra(StatsService.ARG_PERIOD, mStatsTimeframe); + intent.putExtra(StatsService.ARG_DATE, mDate); + if (isSingleView()) { + // Single Item screen: request 20 items per page on paged requests. Default to the first 100 items otherwise. + int maxElementsToRetrieve = pageNumberRequested > 0 ? StatsService.MAX_RESULTS_REQUESTED_PER_PAGE : MAX_RESULTS_REQUESTED; + intent.putExtra(StatsService.ARG_MAX_RESULTS, maxElementsToRetrieve); + } + if (pageNumberRequested > 0) { + intent.putExtra(StatsService.ARG_PAGE_REQUESTED, pageNumberRequested); + } + intent.putExtra(StatsService.ARG_SECTION, sectionsForTheService); + getActivity().startService(intent); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + // AppLog.d(AppLog.T.STATS, this.getClass().getCanonicalName() + " > onCreate"); + + if (savedInstanceState != null) { + if (savedInstanceState.containsKey(ARGS_TIMEFRAME)) { + mStatsTimeframe = (StatsTimeframe) savedInstanceState.getSerializable(ARGS_TIMEFRAME); + } + if (savedInstanceState.containsKey(ARGS_SELECTED_DATE)) { + mDate = savedInstanceState.getString(ARGS_SELECTED_DATE); + } + restoreStatsData(savedInstanceState); // Each fragment will override this to restore fragment dependant data + } + + // AppLog.d(AppLog.T.STATS, "mStatsTimeframe: " + mStatsTimeframe.getLabel()); + // AppLog.d(AppLog.T.STATS, "mDate: " + mDate); + } + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + mResourceVars = new StatsResourceVars(activity); + } + + @Override + public void onSaveInstanceState(Bundle outState) { + /* AppLog.d(AppLog.T.STATS, this.getClass().getCanonicalName() + " > saving instance state"); + AppLog.d(AppLog.T.STATS, "mStatsTimeframe: " + mStatsTimeframe.getLabel()); + AppLog.d(AppLog.T.STATS, "mDate: " + mDate); */ + + outState.putString(ARGS_SELECTED_DATE, mDate); + outState.putSerializable(ARGS_TIMEFRAME, mStatsTimeframe); + saveStatsData(outState); // Each fragment will override this + super.onSaveInstanceState(outState); + } + + @Override + public void onResume() { + super.onResume(); + + // Init the UI + if (hasDataAvailable()) { + updateUI(); + } else { + showPlaceholderUI(); + refreshStats(); + } + } + + @Override + public void onStart() { + super.onStart(); + EventBus.getDefault().register(this); + } + + @Override + public void onStop() { + EventBus.getDefault().unregister(this); + super.onStop(); + } + + public boolean shouldUpdateFragmentOnUpdateEvent(StatsEvents.SectionUpdatedAbstract event) { + if (!isAdded()) { + return false; + } + + if (!getDate().equals(event.mDate)) { + return false; + } + + if (!isSameBlog(event)) { + return false; + } + + if (!event.mTimeframe.equals(getTimeframe())) { + return false; + } + + return true; + } + + boolean isSameBlog(StatsEvents.SectionUpdatedAbstract event) { + final Blog currentBlog = WordPress.getBlog(getLocalTableBlogID()); + if (currentBlog != null && currentBlog.getDotComBlogId() != null) { + return event.mRequestBlogId.equals(currentBlog.getDotComBlogId()); + } + return false; + } + + protected void showErrorUI(VolleyError error) { + if (!isAdded()) { + return; + } + + String label = "<b>" + getString(R.string.error_refresh_stats) + "</b>"; + + if (error instanceof NoConnectionError) { + label += "<br/>" + getString(R.string.no_network_message); + } + + if (StatsUtils.isRESTDisabledError(error)) { + label += "<br/>" + getString(R.string.stats_enable_rest_api_in_jetpack); + } + + showErrorUI(label); + } + + protected void showErrorUI() { + String label = "<b>" + getString(R.string.error_refresh_stats) + "</b>"; + showErrorUI(label); + } + + public boolean shouldUpdateFragmentOnErrorEvent(StatsEvents.SectionUpdateError errorEvent) { + if (!shouldUpdateFragmentOnUpdateEvent(errorEvent)) { + return false; + } + + StatsService.StatsEndpointsEnum sectionToUpdate = errorEvent.mEndPointName; + StatsService.StatsEndpointsEnum[] sectionsToUpdate = sectionsToUpdate(); + + for (int i = 0; i < sectionsToUpdate().length; i++) { + if (sectionToUpdate == sectionsToUpdate[i]) { + return true; + } + } + + return false; + } + + public static StatsAbstractFragment newVisitorsAndViewsInstance(StatsViewType viewType, int localTableBlogID, + StatsTimeframe timeframe, String date, StatsVisitorsAndViewsFragment.OverviewLabel itemToSelect) { + StatsVisitorsAndViewsFragment fragment = (StatsVisitorsAndViewsFragment) newInstance(viewType, localTableBlogID, timeframe, date); + fragment.setSelectedOverviewItem(itemToSelect); + return fragment; + } + + public static StatsAbstractFragment newInstance(StatsViewType viewType, int localTableBlogID, + StatsTimeframe timeframe, String date ) { + StatsAbstractFragment fragment = null; + + switch (viewType) { + //case TIMEFRAME_SELECTOR: + // fragment = new StatsDateSelectorFragment(); + // break; + case GRAPH_AND_SUMMARY: + fragment = new StatsVisitorsAndViewsFragment(); + break; + case TOP_POSTS_AND_PAGES: + fragment = new StatsTopPostsAndPagesFragment(); + break; + case REFERRERS: + fragment = new StatsReferrersFragment(); + break; + case CLICKS: + fragment = new StatsClicksFragment(); + break; + case GEOVIEWS: + fragment = new StatsGeoviewsFragment(); + break; + case AUTHORS: + fragment = new StatsAuthorsFragment(); + break; + case VIDEO_PLAYS: + fragment = new StatsVideoplaysFragment(); + break; + case COMMENTS: + fragment = new StatsCommentsFragment(); + break; + case TAGS_AND_CATEGORIES: + fragment = new StatsTagsAndCategoriesFragment(); + break; + case PUBLICIZE: + fragment = new StatsPublicizeFragment(); + break; + case FOLLOWERS: + fragment = new StatsFollowersFragment(); + break; + case SEARCH_TERMS: + fragment = new StatsSearchTermsFragment(); + break; + case INSIGHTS_MOST_POPULAR: + fragment = new StatsInsightsMostPopularFragment(); + break; + case INSIGHTS_ALL_TIME: + fragment = new StatsInsightsAllTimeFragment(); + break; + case INSIGHTS_TODAY: + fragment = new StatsInsightsTodayFragment(); + break; + case INSIGHTS_LATEST_POST_SUMMARY: + fragment = new StatsInsightsLatestPostSummaryFragment(); + break; + } + + fragment.setTimeframe(timeframe); + fragment.setDate(date); + + Bundle args = new Bundle(); + args.putSerializable(ARGS_VIEW_TYPE, viewType); + args.putInt(StatsActivity.ARG_LOCAL_TABLE_BLOG_ID, localTableBlogID); + fragment.setArguments(args); + + return fragment; + } + + public void setDate(String newDate) { + mDate = newDate; + } + + String getDate() { + return mDate; + } + + public void setTimeframe(StatsTimeframe newTimeframe) { + mStatsTimeframe = newTimeframe; + } + + StatsTimeframe getTimeframe() { + return mStatsTimeframe; + } + + StatsViewType getViewType() { + return (StatsViewType) getArguments().getSerializable(ARGS_VIEW_TYPE); + } + + int getLocalTableBlogID() { + return getArguments().getInt(StatsActivity.ARG_LOCAL_TABLE_BLOG_ID); + } + + boolean isSingleView() { + return getArguments().getBoolean(ARGS_IS_SINGLE_VIEW, false); + } + + protected abstract String getTitle(); +}
\ No newline at end of file diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsAbstractInsightsFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsAbstractInsightsFragment.java new file mode 100644 index 000000000..8f24efd8f --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsAbstractInsightsFragment.java @@ -0,0 +1,85 @@ +package org.wordpress.android.ui.stats; + + +import android.os.Bundle; +import android.text.Html; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.LinearLayout; +import android.widget.TextView; + +import org.wordpress.android.R; + + +public abstract class StatsAbstractInsightsFragment extends StatsAbstractFragment { + public static final String TAG = StatsAbstractInsightsFragment.class.getSimpleName(); + + private TextView mErrorLabel; + private LinearLayout mEmptyModulePlaceholder; + LinearLayout mResultContainer; + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.stats_insights_generic_fragment, container, false); + TextView moduleTitleTextView = (TextView) view.findViewById(R.id.stats_module_title); + moduleTitleTextView.setText(getTitle()); + + mEmptyModulePlaceholder = (LinearLayout) view.findViewById(R.id.stats_empty_module_placeholder); + mResultContainer = (LinearLayout) view.findViewById(R.id.stats_module_result_container); + mErrorLabel = (TextView) view.findViewById(R.id.stats_error_text); + return view; + } + + @Override + protected void showPlaceholderUI() { + mErrorLabel.setVisibility(View.GONE); + mResultContainer.setVisibility(View.GONE); + mEmptyModulePlaceholder.setVisibility(View.VISIBLE); + } + + @Override + protected void showErrorUI(String label) { + if (!isAdded()) { + return; + } + + // Use the generic error message when the string passed to this method is null. + if (TextUtils.isEmpty(label)) { + label = "<b>" + getString(R.string.error_refresh_stats) + "</b>"; + } + + if (label.contains("<")) { + mErrorLabel.setText(Html.fromHtml(label)); + } else { + mErrorLabel.setText(label); + } + mErrorLabel.setVisibility(View.VISIBLE); + mResultContainer.setVisibility(View.GONE); + mEmptyModulePlaceholder.setVisibility(View.GONE); + } + + /** + * Insights module all have the same basic implementation of updateUI. Let's provide a common code here. + */ + @Override + protected void updateUI() { + if (!isAdded()) { + return; + } + + // Another check that the data is available. At this point it should be available. + if (!hasDataAvailable()) { + showErrorUI(); + return; + } + + // not an error - update the module UI here + mErrorLabel.setVisibility(View.GONE); + mResultContainer.setVisibility(View.VISIBLE); + mEmptyModulePlaceholder.setVisibility(View.GONE); + + mResultContainer.removeAllViews(); + } +}
\ No newline at end of file diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsAbstractListFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsAbstractListFragment.java new file mode 100644 index 000000000..d78e1f4c4 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsAbstractListFragment.java @@ -0,0 +1,297 @@ +package org.wordpress.android.ui.stats; + +import android.content.Intent; +import android.os.Bundle; +import android.text.Html; +import android.text.TextUtils; +import android.util.SparseBooleanArray; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.CheckedTextView; +import android.widget.LinearLayout; +import android.widget.RadioGroup; +import android.widget.TextView; + +import org.wordpress.android.R; +import org.wordpress.android.util.DisplayUtils; +import org.wordpress.android.widgets.TypefaceCache; + +public abstract class StatsAbstractListFragment extends StatsAbstractFragment { + + // Used when the fragment has 2 pages/kind of stats in it. Not meaning the bottom pagination. + static final String ARGS_TOP_PAGER_SELECTED_BUTTON_INDEX = "ARGS_TOP_PAGER_SELECTED_BUTTON_INDEX"; + private static final String ARGS_EXPANDED_ROWS = "ARGS_EXPANDED_ROWS"; + private static final int MAX_NUM_OF_ITEMS_DISPLAYED_IN_SINGLE_VIEW_LIST = 1000; + static final int MAX_NUM_OF_ITEMS_DISPLAYED_IN_LIST = 10; + + private static final int NO_STRING_ID = -1; + + private TextView mModuleTitleTextView; + private TextView mEmptyLabel; + TextView mTotalsLabel; + private LinearLayout mListContainer; + LinearLayout mList; + private Button mViewAll; + + LinearLayout mTopPagerContainer; + int mTopPagerSelectedButtonIndex = 0; + + // Bottom and Top Pagination for modules that has pagination enabled. + LinearLayout mBottomPaginationContainer; + Button mBottomPaginationGoBackButton; + Button mBottomPaginationGoForwardButton; + TextView mBottomPaginationText; + LinearLayout mTopPaginationContainer; + Button mTopPaginationGoBackButton; + Button mTopPaginationGoForwardButton; + TextView mTopPaginationText; + + private LinearLayout mEmptyModulePlaceholder; + + SparseBooleanArray mGroupIdToExpandedMap; + + protected abstract int getEntryLabelResId(); + protected abstract int getTotalsLabelResId(); + protected abstract int getEmptyLabelTitleResId(); + protected abstract int getEmptyLabelDescResId(); + protected abstract boolean isExpandableList(); + protected abstract boolean isViewAllOptionAvailable(); + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View view; + if (isExpandableList()) { + view = inflater.inflate(R.layout.stats_expandable_list_fragment, container, false); + } else { + view = inflater.inflate(R.layout.stats_list_fragment, container, false); + } + + mEmptyModulePlaceholder = (LinearLayout) view.findViewById(R.id.stats_empty_module_placeholder); + mModuleTitleTextView = (TextView) view.findViewById(R.id.stats_module_title); + mModuleTitleTextView.setText(getTitle()); + + TextView entryLabel = (TextView) view.findViewById(R.id.stats_list_entry_label); + entryLabel.setText(getEntryLabelResId()); + TextView totalsLabel = (TextView) view.findViewById(R.id.stats_list_totals_label); + totalsLabel.setText(getTotalsLabelResId()); + + mEmptyLabel = (TextView) view.findViewById(R.id.stats_list_empty_text); + mTotalsLabel = (TextView) view.findViewById(R.id.stats_module_totals_label); + mList = (LinearLayout) view.findViewById(R.id.stats_list_linearlayout); + mListContainer = (LinearLayout) view.findViewById(R.id.stats_list_container); + mViewAll = (Button) view.findViewById(R.id.btnViewAll); + mTopPagerContainer = (LinearLayout) view.findViewById(R.id.stats_pager_tabs); + + // Load pagination items + mBottomPaginationContainer = (LinearLayout) view.findViewById(R.id.stats_bottom_pagination_container); + mBottomPaginationGoBackButton = (Button) mBottomPaginationContainer.findViewById(R.id.stats_pagination_go_back); + mBottomPaginationGoForwardButton = (Button) mBottomPaginationContainer.findViewById(R.id.stats_pagination_go_forward); + mBottomPaginationText = (TextView) mBottomPaginationContainer.findViewById(R.id.stats_pagination_text); + mTopPaginationContainer = (LinearLayout) view.findViewById(R.id.stats_top_pagination_container); + mTopPaginationContainer.setBackgroundResource(R.drawable.stats_pagination_item_background); + mTopPaginationGoBackButton = (Button) mTopPaginationContainer.findViewById(R.id.stats_pagination_go_back); + mTopPaginationGoForwardButton = (Button) mTopPaginationContainer.findViewById(R.id.stats_pagination_go_forward); + mTopPaginationText = (TextView) mTopPaginationContainer.findViewById(R.id.stats_pagination_text); + + return view; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mGroupIdToExpandedMap = new SparseBooleanArray(); + if (savedInstanceState != null) { + if (savedInstanceState.containsKey(ARGS_EXPANDED_ROWS)) { + mGroupIdToExpandedMap = savedInstanceState.getParcelable(ARGS_EXPANDED_ROWS); + } + mTopPagerSelectedButtonIndex = savedInstanceState.getInt(ARGS_TOP_PAGER_SELECTED_BUTTON_INDEX); + } + } + + @Override + public void onSaveInstanceState(Bundle outState) { + if (mGroupIdToExpandedMap.size() > 0) { + outState.putParcelable(ARGS_EXPANDED_ROWS, new SparseBooleanArrayParcelable(mGroupIdToExpandedMap)); + } + outState.putInt(ARGS_TOP_PAGER_SELECTED_BUTTON_INDEX, mTopPagerSelectedButtonIndex); + super.onSaveInstanceState(outState); + } + + @Override + protected void showPlaceholderUI() { + mTopPagerContainer.setVisibility(View.GONE); + mEmptyLabel.setVisibility(View.GONE); + mListContainer.setVisibility(View.GONE); + mList.setVisibility(View.GONE); + mViewAll.setVisibility(View.GONE); + mBottomPaginationContainer.setVisibility(View.GONE); + mTopPaginationContainer.setVisibility(View.GONE); + mEmptyModulePlaceholder.setVisibility(View.VISIBLE); + } + + void showHideNoResultsUI(boolean showNoResultsUI) { + mModuleTitleTextView.setVisibility(View.VISIBLE); + mEmptyModulePlaceholder.setVisibility(View.GONE); + + if (showNoResultsUI) { + mGroupIdToExpandedMap.clear(); + String label; + if (getEmptyLabelDescResId() == NO_STRING_ID) { + label = "<b>" + getString(getEmptyLabelTitleResId()) + "</b><br/><br/>"; + } else { + label = "<b>" + getString(getEmptyLabelTitleResId()) + "</b><br/><br/>" + getString(getEmptyLabelDescResId()); + } + if (label.contains("<")) { + mEmptyLabel.setText(Html.fromHtml(label)); + } else { + mEmptyLabel.setText(label); + } + mEmptyLabel.setVisibility(View.VISIBLE); + mListContainer.setVisibility(View.GONE); + mList.setVisibility(View.GONE); + mViewAll.setVisibility(View.GONE); + mBottomPaginationContainer.setVisibility(View.GONE); + mTopPaginationContainer.setVisibility(View.GONE); + } else { + mEmptyLabel.setVisibility(View.GONE); + mListContainer.setVisibility(View.VISIBLE); + mList.setVisibility(View.VISIBLE); + + if (!isSingleView() && isViewAllOptionAvailable()) { + // No view all button if already in single view + configureViewAllButton(); + } else { + mViewAll.setVisibility(View.GONE); + } + } + } + + @Override + protected void showErrorUI(String label) { + if (!isAdded()) { + return; + } + + mGroupIdToExpandedMap.clear(); + mModuleTitleTextView.setVisibility(View.VISIBLE); + mEmptyModulePlaceholder.setVisibility(View.GONE); + + // Use the generic error message when the string passed to this method is null. + if (TextUtils.isEmpty(label)) { + label = "<b>" + getString(R.string.error_refresh_stats) + "</b>"; + } + + if (label.contains("<")) { + mEmptyLabel.setText(Html.fromHtml(label)); + } else { + mEmptyLabel.setText(label); + } + mEmptyLabel.setVisibility(View.VISIBLE); + mListContainer.setVisibility(View.GONE); + mList.setVisibility(View.GONE); + } + + private void configureViewAllButton() { + if (isSingleView()) { + // No view all button if you're already in single view + mViewAll.setVisibility(View.GONE); + return; + } + mViewAll.setVisibility(View.VISIBLE); + mViewAll.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + if (isSingleView()) { + return; // already in single view + } + + if (!hasDataAvailable()) { + return; + } + + Intent viewAllIntent = new Intent(getActivity(), StatsViewAllActivity.class); + viewAllIntent.putExtra(StatsActivity.ARG_LOCAL_TABLE_BLOG_ID, getLocalTableBlogID()); + viewAllIntent.putExtra(StatsAbstractFragment.ARGS_TIMEFRAME, getTimeframe()); + viewAllIntent.putExtra(StatsAbstractFragment.ARGS_VIEW_TYPE, getViewType()); + viewAllIntent.putExtra(StatsAbstractFragment.ARGS_SELECTED_DATE, getDate()); + viewAllIntent.putExtra(ARGS_IS_SINGLE_VIEW, true); + if (mTopPagerContainer.getVisibility() == View.VISIBLE) { + viewAllIntent.putExtra(ARGS_TOP_PAGER_SELECTED_BUTTON_INDEX, mTopPagerSelectedButtonIndex); + } + //viewAllIntent.putExtra(StatsAbstractFragment.ARG_REST_RESPONSE, mDatamodels[mTopPagerSelectedButtonIndex]); + getActivity().startActivity(viewAllIntent); + } + }); + } + + int getMaxNumberOfItemsToShowInList() { + return isSingleView() ? MAX_NUM_OF_ITEMS_DISPLAYED_IN_SINGLE_VIEW_LIST : MAX_NUM_OF_ITEMS_DISPLAYED_IN_LIST; + } + + void setupTopModulePager(LayoutInflater inflater, ViewGroup container, View view, String[] buttonTitles) { + int dp4 = DisplayUtils.dpToPx(view.getContext(), 4); + int dp80 = DisplayUtils.dpToPx(view.getContext(), 80); + + for (int i = 0; i < buttonTitles.length; i++) { + CheckedTextView rb = (CheckedTextView) inflater.inflate(R.layout.stats_top_module_pager_button, container, false); + RadioGroup.LayoutParams params = new RadioGroup.LayoutParams(RadioGroup.LayoutParams.MATCH_PARENT, + RadioGroup.LayoutParams.WRAP_CONTENT); + params.weight = 1; + rb.setTypeface((TypefaceCache.getTypeface(view.getContext()))); + if (i == 0) { + params.setMargins(0, 0, dp4, 0); + } else { + params.setMargins(dp4, 0, 0, 0); + } + rb.setMinimumWidth(dp80); + rb.setGravity(Gravity.CENTER); + rb.setLayoutParams(params); + rb.setText(buttonTitles[i]); + rb.setChecked(i == mTopPagerSelectedButtonIndex); + rb.setOnClickListener(TopModulePagerOnClickListener); + mTopPagerContainer.addView(rb); + } + mTopPagerContainer.setVisibility(View.VISIBLE); + } + + private final View.OnClickListener TopModulePagerOnClickListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + if (!isAdded()) { + return; + } + + CheckedTextView ctv = (CheckedTextView) v; + if (ctv.isChecked()) { + // already checked. Do nothing + return; + } + + int numberOfButtons = mTopPagerContainer.getChildCount(); + int checkedId = -1; + for (int i = 0; i < numberOfButtons; i++) { + CheckedTextView currentCheckedTextView = (CheckedTextView)mTopPagerContainer.getChildAt(i); + if (ctv == currentCheckedTextView) { + checkedId = i; + currentCheckedTextView.setChecked(true); + } else { + currentCheckedTextView.setChecked(false); + } + } + + if (checkedId == -1) + return; + + mTopPagerSelectedButtonIndex = checkedId; + + TextView entryLabel = (TextView) getView().findViewById(R.id.stats_list_entry_label); + if (entryLabel != null) { + entryLabel.setText(getEntryLabelResId()); + } + updateUI(); + } + }; +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsActivity.java new file mode 100644 index 000000000..c3c390e5d --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsActivity.java @@ -0,0 +1,1034 @@ +package org.wordpress.android.ui.stats; + +import android.app.AlertDialog; +import android.app.DialogFragment; +import android.app.FragmentManager; +import android.app.FragmentTransaction; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.os.Bundle; +import android.os.Handler; +import android.support.v7.app.ActionBar; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.widget.Toolbar; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.BaseAdapter; +import android.widget.ScrollView; +import android.widget.Spinner; +import android.widget.TextView; +import android.widget.Toast; + +import org.apache.commons.lang.StringUtils; +import org.wordpress.android.R; +import org.wordpress.android.WordPress; +import org.wordpress.android.analytics.AnalyticsTracker; +import org.wordpress.android.models.AccountHelper; +import org.wordpress.android.models.Blog; +import org.wordpress.android.ui.ActivityId; +import org.wordpress.android.ui.ActivityLauncher; +import org.wordpress.android.ui.WPWebViewActivity; +import org.wordpress.android.ui.accounts.SignInActivity; +import org.wordpress.android.ui.posts.PromoDialog; +import org.wordpress.android.ui.prefs.AppPrefs; +import org.wordpress.android.util.AnalyticsUtils; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; +import org.wordpress.android.util.NetworkUtils; +import org.wordpress.android.util.RateLimitedTask; +import org.wordpress.android.util.ToastUtils; +import org.wordpress.android.util.ToastUtils.Duration; +import org.wordpress.android.util.helpers.SwipeToRefreshHelper; +import org.wordpress.android.util.helpers.SwipeToRefreshHelper.RefreshListener; +import org.wordpress.android.util.widgets.CustomSwipeRefreshLayout; +import org.xmlrpc.android.ApiHelper; +import org.xmlrpc.android.ApiHelper.Method; +import org.xmlrpc.android.XMLRPCCallback; +import org.xmlrpc.android.XMLRPCClientInterface; +import org.xmlrpc.android.XMLRPCFactory; + +import java.util.HashMap; +import java.util.Map; + +import de.greenrobot.event.EventBus; + +/** + * The native stats activity + * <p> + * By pressing a spinner on the action bar, the user can select which timeframe they wish to see. + * </p> + */ +public class StatsActivity extends AppCompatActivity + implements NestedScrollViewExt.ScrollViewListener, + StatsVisitorsAndViewsFragment.OnDateChangeListener, + StatsVisitorsAndViewsFragment.OnOverviewItemChangeListener, + StatsInsightsTodayFragment.OnInsightsTodayClickListener { + + private static final String SAVED_WP_LOGIN_STATE = "SAVED_WP_LOGIN_STATE"; + private static final String SAVED_STATS_TIMEFRAME = "SAVED_STATS_TIMEFRAME"; + private static final String SAVED_STATS_REQUESTED_DATE = "SAVED_STATS_REQUESTED_DATE"; + private static final String SAVED_STATS_SCROLL_POSITION = "SAVED_STATS_SCROLL_POSITION"; + private static final String SAVED_THERE_WAS_AN_ERROR_LOADING_STATS = "SAVED_THERE_WAS_AN_ERROR_LOADING_STATS"; + + private Spinner mSpinner; + private NestedScrollViewExt mOuterScrollView; + + private static final int REQUEST_JETPACK = 7000; + public static final String ARG_LOCAL_TABLE_BLOG_ID = "ARG_LOCAL_TABLE_BLOG_ID"; + public static final String ARG_LAUNCHED_FROM = "ARG_LAUNCHED_FROM"; + public static final String ARG_DESIRED_TIMEFRAME = "ARG_DESIRED_TIMEFRAME"; + + public enum StatsLaunchedFrom { + STATS_WIDGET, + NOTIFICATIONS + } + + private int mResultCode = -1; + private boolean mIsInFront; + private int mLocalBlogID = -1; + private StatsTimeframe mCurrentTimeframe = StatsTimeframe.INSIGHTS; + private String mRequestedDate; + private boolean mIsUpdatingStats; + private SwipeToRefreshHelper mSwipeToRefreshHelper; + private TimeframeSpinnerAdapter mTimeframeSpinnerAdapter; + private final StatsTimeframe[] timeframes = {StatsTimeframe.INSIGHTS, StatsTimeframe.DAY, StatsTimeframe.WEEK, + StatsTimeframe.MONTH, StatsTimeframe.YEAR}; + private StatsVisitorsAndViewsFragment.OverviewLabel mTabToSelectOnGraph = StatsVisitorsAndViewsFragment.OverviewLabel.VIEWS; + + private boolean mThereWasAnErrorLoadingStats = false; + + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + if (WordPress.wpDB == null) { + Toast.makeText(this, R.string.fatal_db_error, Toast.LENGTH_LONG).show(); + finish(); + return; + } + + setContentView(R.layout.stats_activity); + + Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setElevation(0); + actionBar.setTitle(R.string.stats); + actionBar.setDisplayShowTitleEnabled(true); + actionBar.setDisplayHomeAsUpEnabled(true); + } + + mSwipeToRefreshHelper = new SwipeToRefreshHelper(this, (CustomSwipeRefreshLayout) findViewById(R.id.ptr_layout), + new RefreshListener() { + @Override + public void onRefreshStarted() { + + if (!NetworkUtils.checkConnection(getBaseContext())) { + mSwipeToRefreshHelper.setRefreshing(false); + return; + } + + if (mIsUpdatingStats) { + AppLog.w(T.STATS, "stats are already updating, refresh cancelled"); + return; + } + + mRequestedDate = StatsUtils.getCurrentDateTZ(mLocalBlogID); + if (checkCredentials()) { + updateTimeframeAndDateAndStartRefreshOfFragments(true); + } + } + }); + + setTitle(R.string.stats); + + mOuterScrollView = (NestedScrollViewExt) findViewById(R.id.scroll_view_stats); + mOuterScrollView.setScrollViewListener(this); + + if (savedInstanceState != null) { + mResultCode = savedInstanceState.getInt(SAVED_WP_LOGIN_STATE); + mLocalBlogID = savedInstanceState.getInt(ARG_LOCAL_TABLE_BLOG_ID); + mCurrentTimeframe = (StatsTimeframe) savedInstanceState.getSerializable(SAVED_STATS_TIMEFRAME); + mRequestedDate = savedInstanceState.getString(SAVED_STATS_REQUESTED_DATE); + mThereWasAnErrorLoadingStats = savedInstanceState.getBoolean(SAVED_THERE_WAS_AN_ERROR_LOADING_STATS); + final int yScrollPosition = savedInstanceState.getInt(SAVED_STATS_SCROLL_POSITION); + if(yScrollPosition != 0) { + mOuterScrollView.postDelayed(new Runnable() { + public void run() { + if (!isFinishing()) { + mOuterScrollView.scrollTo(0, yScrollPosition); + } + } + }, StatsConstants.STATS_SCROLL_TO_DELAY); + } + } else if (getIntent() != null) { + mLocalBlogID = getIntent().getIntExtra(ARG_LOCAL_TABLE_BLOG_ID, -1); + if (getIntent().hasExtra(SAVED_STATS_TIMEFRAME)) { + mCurrentTimeframe = (StatsTimeframe) getIntent().getSerializableExtra(SAVED_STATS_TIMEFRAME); + } else if (getIntent().hasExtra(ARG_DESIRED_TIMEFRAME)) { + mCurrentTimeframe = (StatsTimeframe) getIntent().getSerializableExtra(ARG_DESIRED_TIMEFRAME); + } else { + // Read the value from app preferences here. Default to 0 - Insights + mCurrentTimeframe = AppPrefs.getStatsTimeframe(); + } + mRequestedDate = StatsUtils.getCurrentDateTZ(mLocalBlogID); + + if (getIntent().hasExtra(ARG_LAUNCHED_FROM)) { + StatsLaunchedFrom from = (StatsLaunchedFrom) getIntent().getSerializableExtra(ARG_LAUNCHED_FROM); + if (from == StatsLaunchedFrom.STATS_WIDGET) { + AnalyticsUtils.trackWithBlogDetails(AnalyticsTracker.Stat.STATS_WIDGET_TAPPED, WordPress.getBlog(mLocalBlogID)); + } + } + + } + + //Make sure the blog_id passed to this activity is valid and the blog is available within the app + final Blog currentBlog = WordPress.getBlog(mLocalBlogID); + + if (currentBlog == null) { + AppLog.e(T.STATS, "The blog with local_blog_id " + mLocalBlogID + " cannot be loaded from the DB."); + Toast.makeText(this, R.string.stats_no_blog, Toast.LENGTH_LONG).show(); + finish(); + return; + } + + // create the fragments without forcing the re-creation. If the activity is restarted fragments can already + // be there, and ready to be displayed without making any network connections. A fragment calls the stats service + // if its internal datamodel is empty. + createFragments(false); + + if (mSpinner == null) { + mSpinner = (Spinner) findViewById(R.id.filter_spinner); + + mTimeframeSpinnerAdapter = new TimeframeSpinnerAdapter(this, timeframes); + + mSpinner.setAdapter(mTimeframeSpinnerAdapter); + mSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView<?> parent, View view, int position, long id) { + if (isFinishing()) { + return; + } + final StatsTimeframe selectedTimeframe = (StatsTimeframe) mTimeframeSpinnerAdapter.getItem(position); + + if (mCurrentTimeframe == selectedTimeframe) { + AppLog.d(T.STATS, "The selected TIME FRAME is already active: " + selectedTimeframe.getLabel()); + return; + } + + AppLog.d(T.STATS, "NEW TIME FRAME : " + selectedTimeframe.getLabel()); + mCurrentTimeframe = selectedTimeframe; + AppPrefs.setStatsTimeframe(mCurrentTimeframe); + mRequestedDate = StatsUtils.getCurrentDateTZ(mLocalBlogID); + createFragments(true); // Need to recreate fragment here, since a new timeline was selected. + mSpinner.postDelayed(new Runnable() { + @Override + public void run() { + if (!isFinishing()) { + scrollToTop(); + } + } + }, StatsConstants.STATS_SCROLL_TO_DELAY); + + trackStatsAnalytics(); + } + @Override + public void onNothingSelected(AdapterView<?> parent) { + // nop + } + }); + + Toolbar spinnerToolbar = (Toolbar) findViewById(R.id.toolbar_filter); + spinnerToolbar.setBackgroundColor(getResources().getColor(R.color.blue_medium)); + + } + + selectCurrentTimeframeInActionBar(); + + TextView otherRecentStatsMovedLabel = (TextView) findViewById(R.id.stats_other_recent_stats_moved); + otherRecentStatsMovedLabel.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + for (int i = 0; i < timeframes.length; i++) { + if (timeframes[i] == StatsTimeframe.INSIGHTS) { + mSpinner.setSelection(i); + break; + } + } + + mSpinner.postDelayed(new Runnable() { + @Override + public void run() { + if (!isFinishing()) { + scrollToTop(); + } + } + }, StatsConstants.STATS_SCROLL_TO_DELAY); + } + }); + + // Track usage here + if (savedInstanceState == null) { + AnalyticsUtils.trackWithBlogDetails(AnalyticsTracker.Stat.STATS_ACCESSED, currentBlog); + trackStatsAnalytics(); + } + } + + private void trackStatsAnalytics() { + // Track usage here + Blog currentBlog = WordPress.getBlog(mLocalBlogID); + switch (mCurrentTimeframe) { + case INSIGHTS: + AnalyticsUtils.trackWithBlogDetails(AnalyticsTracker.Stat.STATS_INSIGHTS_ACCESSED, currentBlog); + break; + case DAY: + AnalyticsUtils.trackWithBlogDetails(AnalyticsTracker.Stat.STATS_PERIOD_DAYS_ACCESSED, currentBlog); + break; + case WEEK: + AnalyticsUtils.trackWithBlogDetails(AnalyticsTracker.Stat.STATS_PERIOD_WEEKS_ACCESSED, currentBlog); + break; + case MONTH: + AnalyticsUtils.trackWithBlogDetails(AnalyticsTracker.Stat.STATS_PERIOD_MONTHS_ACCESSED, currentBlog); + break; + case YEAR: + AnalyticsUtils.trackWithBlogDetails(AnalyticsTracker.Stat.STATS_PERIOD_YEARS_ACCESSED, currentBlog); + break; + } + } + + @Override + protected void onStop() { + EventBus.getDefault().unregister(this); + super.onStop(); + } + + @Override + protected void onStart() { + super.onStart(); + EventBus.getDefault().register(this); + } + + @Override + protected void onResume() { + super.onResume(); + mIsInFront = true; + if (NetworkUtils.checkConnection(this)) { + checkCredentials(); + } else { + mSwipeToRefreshHelper.setRefreshing(false); + } + ActivityId.trackLastActivity(ActivityId.STATS); + } + + @Override + protected void onPause() { + super.onPause(); + mIsInFront = false; + mIsUpdatingStats = false; + mSwipeToRefreshHelper.setRefreshing(false); + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + outState.putInt(SAVED_WP_LOGIN_STATE, mResultCode); + outState.putInt(ARG_LOCAL_TABLE_BLOG_ID, mLocalBlogID); + outState.putSerializable(SAVED_STATS_TIMEFRAME, mCurrentTimeframe); + outState.putString(SAVED_STATS_REQUESTED_DATE, mRequestedDate); + outState.putBoolean(SAVED_THERE_WAS_AN_ERROR_LOADING_STATS, mThereWasAnErrorLoadingStats); + if (mOuterScrollView.getScrollY() != 0) { + outState.putInt(SAVED_STATS_SCROLL_POSITION, mOuterScrollView.getScrollY()); + } + super.onSaveInstanceState(outState); + } + + private void createFragments(boolean forceRecreationOfFragments) { + if (isFinishing()) { + return; + } + + // Make the labels invisible see: https://github.com/wordpress-mobile/WordPress-Android/issues/3279 + findViewById(R.id.stats_other_recent_stats_label_insights).setVisibility(View.INVISIBLE); + findViewById(R.id.stats_other_recent_stats_label_timeline).setVisibility(View.INVISIBLE); + findViewById(R.id.stats_other_recent_stats_moved).setVisibility(View.INVISIBLE); + + FragmentManager fm = getFragmentManager(); + FragmentTransaction ft = fm.beginTransaction(); + + StatsAbstractFragment fragment; + + if (mCurrentTimeframe != StatsTimeframe.INSIGHTS) { + findViewById(R.id.stats_timeline_fragments_container).setVisibility(View.VISIBLE); + findViewById(R.id.stats_insights_fragments_container).setVisibility(View.GONE); + + if (fm.findFragmentByTag(StatsVisitorsAndViewsFragment.TAG) == null || forceRecreationOfFragments) { + fragment = StatsAbstractFragment.newVisitorsAndViewsInstance(StatsViewType.GRAPH_AND_SUMMARY, mLocalBlogID, mCurrentTimeframe, mRequestedDate, + mTabToSelectOnGraph); + ft.replace(R.id.stats_visitors_and_views_container, fragment, StatsVisitorsAndViewsFragment.TAG); + } + + if (fm.findFragmentByTag(StatsTopPostsAndPagesFragment.TAG) == null || forceRecreationOfFragments) { + fragment = StatsAbstractFragment.newInstance(StatsViewType.TOP_POSTS_AND_PAGES, mLocalBlogID, mCurrentTimeframe, mRequestedDate); + ft.replace(R.id.stats_top_posts_container, fragment, StatsTopPostsAndPagesFragment.TAG); + } + + if (fm.findFragmentByTag(StatsReferrersFragment.TAG) == null || forceRecreationOfFragments) { + fragment = StatsAbstractFragment.newInstance(StatsViewType.REFERRERS, mLocalBlogID, mCurrentTimeframe, mRequestedDate); + ft.replace(R.id.stats_referrers_container, fragment, StatsReferrersFragment.TAG); + } + + if (fm.findFragmentByTag(StatsClicksFragment.TAG) == null || forceRecreationOfFragments) { + fragment = StatsAbstractFragment.newInstance(StatsViewType.CLICKS, mLocalBlogID, mCurrentTimeframe, mRequestedDate); + ft.replace(R.id.stats_clicks_container, fragment, StatsClicksFragment.TAG); + } + + if (fm.findFragmentByTag(StatsGeoviewsFragment.TAG) == null || forceRecreationOfFragments) { + fragment = StatsAbstractFragment.newInstance(StatsViewType.GEOVIEWS, mLocalBlogID, mCurrentTimeframe, mRequestedDate); + ft.replace(R.id.stats_geoviews_container, fragment, StatsGeoviewsFragment.TAG); + } + + if (fm.findFragmentByTag(StatsAuthorsFragment.TAG) == null || forceRecreationOfFragments) { + fragment = StatsAbstractFragment.newInstance(StatsViewType.AUTHORS, mLocalBlogID, mCurrentTimeframe, mRequestedDate); + ft.replace(R.id.stats_top_authors_container, fragment, StatsAuthorsFragment.TAG); + } + + if (fm.findFragmentByTag(StatsVideoplaysFragment.TAG) == null || forceRecreationOfFragments) { + fragment = StatsAbstractFragment.newInstance(StatsViewType.VIDEO_PLAYS, mLocalBlogID, mCurrentTimeframe, mRequestedDate); + ft.replace(R.id.stats_video_container, fragment, StatsVideoplaysFragment.TAG); + } + + if (fm.findFragmentByTag(StatsSearchTermsFragment.TAG) == null || forceRecreationOfFragments) { + fragment = StatsAbstractFragment.newInstance(StatsViewType.SEARCH_TERMS, mLocalBlogID, mCurrentTimeframe, mRequestedDate); + ft.replace(R.id.stats_search_terms_container, fragment, StatsSearchTermsFragment.TAG); + } + + } else { + findViewById(R.id.stats_timeline_fragments_container).setVisibility(View.GONE); + findViewById(R.id.stats_insights_fragments_container).setVisibility(View.VISIBLE); + + if (fm.findFragmentByTag(StatsInsightsMostPopularFragment.TAG) == null || forceRecreationOfFragments) { + fragment = StatsAbstractFragment.newInstance(StatsViewType.INSIGHTS_MOST_POPULAR, mLocalBlogID, mCurrentTimeframe, mRequestedDate); + ft.replace(R.id.stats_insights_most_popular_container, fragment, StatsInsightsMostPopularFragment.TAG); + } + + if (fm.findFragmentByTag(StatsInsightsAllTimeFragment.TAG) == null || forceRecreationOfFragments) { + fragment = StatsAbstractFragment.newInstance(StatsViewType.INSIGHTS_ALL_TIME, mLocalBlogID, mCurrentTimeframe, mRequestedDate); + ft.replace(R.id.stats_insights_all_time_container, fragment, StatsInsightsAllTimeFragment.TAG); + } + + if (fm.findFragmentByTag(StatsInsightsTodayFragment.TAG) == null || forceRecreationOfFragments) { + fragment = StatsAbstractFragment.newInstance(StatsViewType.INSIGHTS_TODAY, mLocalBlogID, StatsTimeframe.DAY, mRequestedDate); + ft.replace(R.id.stats_insights_today_container, fragment, StatsInsightsTodayFragment.TAG); + } + + if (fm.findFragmentByTag(StatsInsightsLatestPostSummaryFragment.TAG) == null || forceRecreationOfFragments) { + fragment = StatsAbstractFragment.newInstance(StatsViewType.INSIGHTS_LATEST_POST_SUMMARY, mLocalBlogID, mCurrentTimeframe, mRequestedDate); + ft.replace(R.id.stats_insights_latest_post_summary_container, fragment, StatsInsightsLatestPostSummaryFragment.TAG); + } + + if (fm.findFragmentByTag(StatsCommentsFragment.TAG) == null || forceRecreationOfFragments) { + fragment = StatsAbstractFragment.newInstance(StatsViewType.COMMENTS, mLocalBlogID, mCurrentTimeframe, mRequestedDate); + ft.replace(R.id.stats_comments_container, fragment, StatsCommentsFragment.TAG); + } + + if (fm.findFragmentByTag(StatsTagsAndCategoriesFragment.TAG) == null || forceRecreationOfFragments) { + fragment = StatsAbstractFragment.newInstance(StatsViewType.TAGS_AND_CATEGORIES, mLocalBlogID, mCurrentTimeframe, mRequestedDate); + ft.replace(R.id.stats_tags_and_categories_container, fragment, StatsTagsAndCategoriesFragment.TAG); + } + + if (fm.findFragmentByTag(StatsPublicizeFragment.TAG) == null || forceRecreationOfFragments) { + fragment = StatsAbstractFragment.newInstance(StatsViewType.PUBLICIZE, mLocalBlogID, mCurrentTimeframe, mRequestedDate); + ft.replace(R.id.stats_publicize_container, fragment, StatsPublicizeFragment.TAG); + } + + if (fm.findFragmentByTag(StatsFollowersFragment.TAG) == null || forceRecreationOfFragments) { + fragment = StatsAbstractFragment.newInstance(StatsViewType.FOLLOWERS, mLocalBlogID, mCurrentTimeframe, mRequestedDate); + ft.replace(R.id.stats_followers_container, fragment, StatsFollowersFragment.TAG); + } + } + + ft.commitAllowingStateLoss(); + + // Slightly delayed labels setup: see https://github.com/wordpress-mobile/WordPress-Android/issues/3279 + mOuterScrollView.postDelayed(new Runnable() { + @Override + public void run() { + if (isFinishing()) { + return; + } + boolean isInsights = mCurrentTimeframe == StatsTimeframe.INSIGHTS; + findViewById(R.id.stats_other_recent_stats_label_insights).setVisibility(isInsights ? View.VISIBLE : View.GONE); + findViewById(R.id.stats_other_recent_stats_label_timeline).setVisibility(isInsights ? View.GONE : View.VISIBLE); + findViewById(R.id.stats_other_recent_stats_moved).setVisibility(isInsights ? View.GONE : View.VISIBLE); + } + }, StatsConstants.STATS_LABELS_SETUP_DELAY); + } + + private void updateTimeframeAndDateAndStartRefreshOfFragments(boolean includeGraph) { + if (isFinishing()) { + return; + } + FragmentManager fm = getFragmentManager(); + + if (mCurrentTimeframe != StatsTimeframe.INSIGHTS) { + updateTimeframeAndDateAndStartRefreshInFragment(fm, StatsTopPostsAndPagesFragment.TAG); + updateTimeframeAndDateAndStartRefreshInFragment(fm, StatsReferrersFragment.TAG); + updateTimeframeAndDateAndStartRefreshInFragment(fm, StatsClicksFragment.TAG); + updateTimeframeAndDateAndStartRefreshInFragment(fm, StatsGeoviewsFragment.TAG); + updateTimeframeAndDateAndStartRefreshInFragment(fm, StatsAuthorsFragment.TAG); + updateTimeframeAndDateAndStartRefreshInFragment(fm, StatsVideoplaysFragment.TAG); + updateTimeframeAndDateAndStartRefreshInFragment(fm, StatsSearchTermsFragment.TAG); + if (includeGraph) { + updateTimeframeAndDateAndStartRefreshInFragment(fm, StatsVisitorsAndViewsFragment.TAG); + } + } else { + updateTimeframeAndDateAndStartRefreshInFragment(fm, StatsInsightsTodayFragment.TAG); + updateTimeframeAndDateAndStartRefreshInFragment(fm, StatsInsightsAllTimeFragment.TAG); + updateTimeframeAndDateAndStartRefreshInFragment(fm, StatsInsightsMostPopularFragment.TAG); + updateTimeframeAndDateAndStartRefreshInFragment(fm, StatsInsightsLatestPostSummaryFragment.TAG); + updateTimeframeAndDateAndStartRefreshInFragment(fm, StatsCommentsFragment.TAG); + updateTimeframeAndDateAndStartRefreshInFragment(fm, StatsTagsAndCategoriesFragment.TAG); + updateTimeframeAndDateAndStartRefreshInFragment(fm, StatsPublicizeFragment.TAG); + updateTimeframeAndDateAndStartRefreshInFragment(fm, StatsFollowersFragment.TAG); + } + } + + private boolean updateTimeframeAndDateAndStartRefreshInFragment(FragmentManager fm , String fragmentTAG) { + StatsAbstractFragment fragment = (StatsAbstractFragment) fm.findFragmentByTag(fragmentTAG); + if (fragment != null) { + fragment.setDate(mRequestedDate); + fragment.setTimeframe(mCurrentTimeframe); + fragment.refreshStats(); + return true; + } + return false; + } + + private void startWPComLoginActivity() { + mResultCode = RESULT_CANCELED; + Intent signInIntent = new Intent(this, SignInActivity.class); + signInIntent.putExtra(SignInActivity.EXTRA_JETPACK_SITE_AUTH, mLocalBlogID); + signInIntent.putExtra( + SignInActivity.EXTRA_JETPACK_MESSAGE_AUTH, + getString(R.string.stats_sign_in_jetpack_different_com_account) + ); + startActivityForResult(signInIntent, SignInActivity.REQUEST_CODE); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == SignInActivity.REQUEST_CODE) { + if (resultCode == RESULT_CANCELED) { + finish(); + } + mResultCode = resultCode; + final Blog currentBlog = WordPress.getBlog(mLocalBlogID); + if (resultCode == RESULT_OK && currentBlog != null && !currentBlog.isDotcomFlag()) { + if (currentBlog.getDotComBlogId() == null) { + final Handler handler = new Handler(); + // Attempt to get the Jetpack blog ID + XMLRPCClientInterface xmlrpcClient = XMLRPCFactory.instantiate(currentBlog.getUri(), "", ""); + Map<String, String> args = ApiHelper.blogOptionsXMLRPCParameters; + Object[] params = { + currentBlog.getRemoteBlogId(), currentBlog.getUsername(), currentBlog.getPassword(), args + }; + xmlrpcClient.callAsync(new XMLRPCCallback() { + @Override + public void onSuccess(long id, Object result) { + if (result != null && (result instanceof HashMap)) { + Map<?, ?> blogOptions = (HashMap<?, ?>) result; + ApiHelper.updateBlogOptions(currentBlog, blogOptions); + AnalyticsUtils.refreshMetadata(); + AnalyticsUtils.trackWithBlogDetails(AnalyticsTracker.Stat.SIGNED_INTO_JETPACK, currentBlog); + AnalyticsUtils.trackWithBlogDetails( + AnalyticsTracker.Stat.PERFORMED_JETPACK_SIGN_IN_FROM_STATS_SCREEN, currentBlog); + if (isFinishing()) { + return; + } + // We have the blogID now, but we need to re-check if the network connection is available + if (NetworkUtils.checkConnection(StatsActivity.this)) { + handler.post(new Runnable() { + @Override + public void run() { + mSwipeToRefreshHelper.setRefreshing(true); + mRequestedDate = StatsUtils.getCurrentDateTZ(mLocalBlogID); + createFragments(true); // Recreate the fragment and start a refresh of Stats + } + }); + } + } + } + @Override + public void onFailure(long id, Exception error) { + AppLog.e(T.STATS, + "Cannot load blog options (wp.getOptions failed) " + + "and no jetpack_client_id is then available", + error); + handler.post(new Runnable() { + @Override + public void run() { + mSwipeToRefreshHelper.setRefreshing(false); + ToastUtils.showToast(StatsActivity.this, + StatsActivity.this.getString(R.string.error_refresh_stats), + Duration.LONG); + } + }); + } + }, Method.GET_OPTIONS, params); + } else { + mRequestedDate = StatsUtils.getCurrentDateTZ(mLocalBlogID); + createFragments(true); // Recreate the fragment and start a refresh of Stats + } + mSwipeToRefreshHelper.setRefreshing(true); + } + } + } + + private class VerifyJetpackSettingsCallback implements ApiHelper.GenericCallback { + // AsyncTasks are bound to the Activity that launched it. If the user rotate the device StatsActivity is restarted. + // Use the event bus to fix this issue. + + @Override + public void onSuccess() { + EventBus.getDefault().post(new StatsEvents.JetpackSettingsCompleted(false)); + } + + @Override + public void onFailure(ApiHelper.ErrorType errorType, String errorMessage, Throwable throwable) { + EventBus.getDefault().post(new StatsEvents.JetpackSettingsCompleted(true)); + } + } + + private void showJetpackNonConnectedAlert() { + if (isFinishing()) { + return; + } + AlertDialog.Builder builder = new AlertDialog.Builder(this); + final Blog currentBlog = WordPress.getBlog(mLocalBlogID); + if (currentBlog == null) { + AppLog.e(T.STATS, "The blog with local_blog_id " + mLocalBlogID + " cannot be loaded from the DB."); + Toast.makeText(this, R.string.stats_no_blog, Toast.LENGTH_LONG).show(); + finish(); + return; + } + if (currentBlog.isAdmin()) { + builder.setMessage(getString(R.string.jetpack_not_connected_message)) + .setTitle(getString(R.string.jetpack_not_connected)); + builder.setPositiveButton(R.string.yes, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int id) { + String stringToLoad = currentBlog.getAdminUrl(); + String jetpackConnectPageAdminPath = "admin.php?page=jetpack"; + stringToLoad = stringToLoad.endsWith("/") ? stringToLoad + jetpackConnectPageAdminPath : + stringToLoad + "/" + jetpackConnectPageAdminPath; + String authURL = WPWebViewActivity.getBlogLoginUrl(currentBlog); + Intent jetpackIntent = new Intent(StatsActivity.this, WPWebViewActivity.class); + jetpackIntent.putExtra(WPWebViewActivity.AUTHENTICATION_USER, currentBlog.getUsername()); + jetpackIntent.putExtra(WPWebViewActivity.AUTHENTICATION_PASSWD, currentBlog.getPassword()); + jetpackIntent.putExtra(WPWebViewActivity.URL_TO_LOAD, stringToLoad); + jetpackIntent.putExtra(WPWebViewActivity.AUTHENTICATION_URL, authURL); + startActivityForResult(jetpackIntent, REQUEST_JETPACK); + AnalyticsTracker.track(AnalyticsTracker.Stat.STATS_SELECTED_CONNECT_JETPACK); + } + }); + builder.setNegativeButton(R.string.no, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int id) { + // User cancelled the dialog. Hide Stats. + finish(); + } + }); + } else { + builder.setMessage(getString(R.string.jetpack_message_not_admin)) + .setTitle(getString(R.string.jetpack_not_found)); + builder.setPositiveButton(R.string.yes, null); + } + + AlertDialog dialog = builder.create(); + dialog.setOnCancelListener(new DialogInterface.OnCancelListener() { + @Override + public void onCancel(DialogInterface dialog) { + // User pressed the back key Hide Stats. + finish(); + } + }); + dialog.show(); + } + + private void showJetpackMissingAlert() { + if (isFinishing()) { + return; + } + AlertDialog.Builder builder = new AlertDialog.Builder(this); + final Blog currentBlog = WordPress.getBlog(mLocalBlogID); + if (currentBlog == null) { + AppLog.e(T.STATS, "The blog with local_blog_id " + mLocalBlogID + " cannot be loaded from the DB."); + Toast.makeText(this, R.string.stats_no_blog, Toast.LENGTH_LONG).show(); + finish(); + return; + } + if (currentBlog.isAdmin()) { + builder.setMessage(getString(R.string.jetpack_message)) + .setTitle(getString(R.string.jetpack_not_found)); + builder.setPositiveButton(R.string.yes, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int id) { + String stringToLoad = currentBlog.getAdminUrl() + + "plugin-install.php?tab=search&s=jetpack+by+wordpress.com" + + "&plugin-search-input=Search+Plugins"; + String authURL = WPWebViewActivity.getBlogLoginUrl(currentBlog); + Intent jetpackIntent = new Intent(StatsActivity.this, WPWebViewActivity.class); + jetpackIntent.putExtra(WPWebViewActivity.AUTHENTICATION_USER, currentBlog.getUsername()); + jetpackIntent.putExtra(WPWebViewActivity.AUTHENTICATION_PASSWD, currentBlog.getPassword()); + jetpackIntent.putExtra(WPWebViewActivity.URL_TO_LOAD, stringToLoad); + jetpackIntent.putExtra(WPWebViewActivity.AUTHENTICATION_URL, authURL); + startActivityForResult(jetpackIntent, REQUEST_JETPACK); + AnalyticsTracker.track(AnalyticsTracker.Stat.STATS_SELECTED_INSTALL_JETPACK); + } + }); + builder.setNegativeButton(R.string.no, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int id) { + // User cancelled the dialog. Hide Stats. + finish(); + } + }); + } else { + builder.setMessage(getString(R.string.jetpack_message_not_admin)) + .setTitle(getString(R.string.jetpack_not_found)); + builder.setPositiveButton(R.string.yes, null); + } + + AlertDialog dialog = builder.create(); + dialog.setOnCancelListener(new DialogInterface.OnCancelListener() { + @Override + public void onCancel(DialogInterface dialog) { + // User pressed the back key Hide Stats. + finish(); + } + }); + dialog.show(); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + int i = item.getItemId(); + if (i == android.R.id.home) { + onBackPressed(); + return true; + } + + return super.onOptionsItemSelected(item); + } + + private void scrollToTop() { + mOuterScrollView.fullScroll(ScrollView.FOCUS_UP); + } + + // StatsInsightsTodayFragment calls this when the user taps on a item in Today's Stats + @Override + public void onInsightsTodayClicked(final StatsVisitorsAndViewsFragment.OverviewLabel item) { + mTabToSelectOnGraph = item; + for (int i = 0; i < timeframes.length; i++) { + if (timeframes[i] == StatsTimeframe.DAY) { + mSpinner.setSelection(i); + break; + } + } + } + + // StatsVisitorsAndViewsFragment calls this when the user taps on a bar in the graph + @Override + public void onDateChanged(String blogID, StatsTimeframe timeframe, String date) { + if (isFinishing()) { + return; + } + mRequestedDate = date; + updateTimeframeAndDateAndStartRefreshOfFragments(false); + if (NetworkUtils.checkConnection(StatsActivity.this)) { + mSwipeToRefreshHelper.setRefreshing(true); + } else { + mSwipeToRefreshHelper.setRefreshing(false); + } + } + + // StatsVisitorsAndViewsFragment calls this when the user taps on the tab bar to change the type of the graph + @Override + public void onOverviewItemChanged(StatsVisitorsAndViewsFragment.OverviewLabel newItem) { + mTabToSelectOnGraph = newItem; + } + + private boolean checkCredentials() { + if (!NetworkUtils.isNetworkAvailable(this)) { + AppLog.w(AppLog.T.STATS, "StatsActivity > cannot check credentials since no internet connection available"); + return false; + } + + final Blog currentBlog = WordPress.getBlog(mLocalBlogID); + if (currentBlog == null) { + AppLog.e(T.STATS, "The blog with local_blog_id " + mLocalBlogID + " cannot be loaded from the DB."); + return false; + } + + final String blogId = currentBlog.getDotComBlogId(); + + // blogId is always available for dotcom blogs. It could be null on Jetpack blogs... + if (blogId != null) { + // for self-hosted sites; launch the user into an activity where they can provide their credentials + if (!currentBlog.isDotcomFlag() + && !currentBlog.hasValidJetpackCredentials() && mResultCode != RESULT_CANCELED) { + if (AccountHelper.isSignedInWordPressDotCom()) { + // Let's try the global wpcom credentials them first + String username = AccountHelper.getDefaultAccount().getUserName(); + currentBlog.setDotcom_username(username); + WordPress.wpDB.saveBlog(currentBlog); + createFragments(true); + } else { + startWPComLoginActivity(); + return false; + } + } + } else { + // blogId is null at this point. + if (!currentBlog.isDotcomFlag()) { + // Refresh blog settings/options that includes 'jetpack_client_id' needed here + mSwipeToRefreshHelper.setRefreshing(true); + new ApiHelper.RefreshBlogContentTask(currentBlog, + new VerifyJetpackSettingsCallback()).execute(false); + return false; + } else { + // blodID cannot be null on dotcom blogs. + Toast.makeText(this, R.string.error_refresh_stats, Toast.LENGTH_LONG).show(); + AppLog.e(T.STATS, "blogID is null for a wpcom blog!! " + currentBlog.getHomeURL()); + finish(); + } + } + + return true; + } + + private void bumpPromoAnaylticsAndShowPromoDialogIfNecessary() { + if (mIsUpdatingStats || mThereWasAnErrorLoadingStats) { + // Do nothing in case of errors or when it's still loading + return; + } + + if (!StringUtils.isEmpty(AppPrefs.getStatsWidgetsKeys())) { + // Stats widgets already used!! + return; + } + + // Bump analytics that drives the Promo widget when the loading is completed without errors. + AppPrefs.bumpAnalyticsForStatsWidgetPromo(); + + // Should we display the widget promo? + int counter = AppPrefs.getAnalyticsForStatsWidgetPromo(); + if (counter == 3 || counter == 1000 || counter == 10000) { + DialogFragment newFragment = PromoDialog.newInstance(R.drawable.stats_widget_promo_header, + R.string.stats_widget_promo_title, R.string.stats_widget_promo_desc, + R.string.stats_widget_promo_ok_btn_label); + newFragment.show(getFragmentManager(), "promote_widget_dialog"); + } + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.UpdateStatusChanged event) { + if (isFinishing() || !mIsInFront) { + return; + } + mSwipeToRefreshHelper.setRefreshing(event.mUpdating); + mIsUpdatingStats = event.mUpdating; + + if (!mIsUpdatingStats && !mThereWasAnErrorLoadingStats) { + // Do not bump promo analytics or show the dialog in case of errors or when it's still loading + bumpPromoAnaylticsAndShowPromoDialogIfNecessary(); + } + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.JetpackSettingsCompleted event) { + if (isFinishing() || !mIsInFront) { + return; + } + mSwipeToRefreshHelper.setRefreshing(false); + + if (!event.isError) { + final Blog currentBlog = WordPress.getBlog(mLocalBlogID); + if (currentBlog == null) { + AppLog.e(T.STATS, "The blog with local_blog_id " + mLocalBlogID + " cannot be loaded from the DB."); + Toast.makeText(this, R.string.stats_no_blog, Toast.LENGTH_LONG).show(); + finish(); + return; + } + if (currentBlog.getDotComBlogId() == null) { + if (TextUtils.isEmpty(currentBlog.getJetpackVersion())) { + // jetpack_version option is available, but not the jetpack_client_id ----> Jetpack available but not connected. + showJetpackNonConnectedAlert(); + } else { + // Blog has not returned jetpack_version/jetpack_client_id. + showJetpackMissingAlert(); + } + } else { + checkCredentials(); + } + } else { + Toast.makeText(StatsActivity.this, R.string.error_refresh_stats, Toast.LENGTH_LONG).show(); + finish(); + } + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.SectionUpdateError event) { + // There was an error loading Stats. Don't bump stats for promo widget. + if (isFinishing() || !mIsInFront) { + return; + } + + // There was an error loading Stats. Don't bump stats for promo widget. + mThereWasAnErrorLoadingStats = true; + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.JetpackAuthError event) { + if (isFinishing() || !mIsInFront) { + return; + } + + // There was an error loading Stats. Don't bump stats for promo widget. + mThereWasAnErrorLoadingStats = true; + + if (event.mLocalBlogId != mLocalBlogID) { + // The user has changed blog + return; + } + + mSwipeToRefreshHelper.setRefreshing(false); + startWPComLoginActivity(); + } + + /* + * make sure the passed timeframe is the one selected in the actionbar + */ + private void selectCurrentTimeframeInActionBar() { + if (isFinishing()) { + return; + } + + if (mTimeframeSpinnerAdapter == null || mSpinner == null) { + return; + } + + int position = mTimeframeSpinnerAdapter.getIndexOfTimeframe(mCurrentTimeframe); + + if (position > -1 && position != mSpinner.getSelectedItemPosition()) { + mSpinner.setSelection(position); + } + } + + /* + * adapter used by the timeframe spinner + */ + private class TimeframeSpinnerAdapter extends BaseAdapter { + private final StatsTimeframe[] mTimeframes; + private final LayoutInflater mInflater; + + TimeframeSpinnerAdapter(Context context, StatsTimeframe[] timeframeNames) { + super(); + mInflater = (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + mTimeframes = timeframeNames; + } + + @Override + public int getCount() { + return (mTimeframes != null ? mTimeframes.length : 0); + } + + @Override + public Object getItem(int position) { + if (position < 0 || position >= getCount()) + return ""; + return mTimeframes[position]; + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + final View view; + if (convertView == null) { + view = mInflater.inflate(R.layout.filter_spinner_item, parent, false); + } else { + view = convertView; + } + + final TextView text = (TextView) view.findViewById(R.id.text); + StatsTimeframe selectedTimeframe = (StatsTimeframe)getItem(position); + text.setText(selectedTimeframe.getLabel()); + return view; + } + + @Override + public View getDropDownView(int position, View convertView, ViewGroup parent) { + StatsTimeframe selectedTimeframe = (StatsTimeframe)getItem(position); + final TagViewHolder holder; + + if (convertView == null) { + convertView = mInflater.inflate(R.layout.toolbar_spinner_dropdown_item, parent, false); + holder = new TagViewHolder(convertView); + convertView.setTag(holder); + } else { + holder = (TagViewHolder) convertView.getTag(); + } + + holder.textView.setText(selectedTimeframe.getLabel()); + return convertView; + } + + private class TagViewHolder { + private final TextView textView; + TagViewHolder(View view) { + textView = (TextView) view.findViewById(R.id.text); + } + } + + public int getIndexOfTimeframe(StatsTimeframe tm) { + int pos = 0; + for (int i = 0; i < mTimeframes.length; i++) { + if (mTimeframes[i] == tm) { + pos = i; + return pos; + } + } + return pos; + } + } + + @Override + public void onScrollChanged(NestedScrollViewExt scrollView, int x, int y, int oldx, int oldy) { + // We take the last son in the scrollview + View view = scrollView.getChildAt(scrollView.getChildCount() - 1); + if (view == null) { + return; + } + int diff = (view.getBottom() - (scrollView.getHeight() + scrollView.getScrollY() + view.getTop())); + + // if diff is zero, then the bottom has been reached + if (diff == 0) { + sTrackBottomReachedStats.runIfNotLimited(); + } + } + + private static final RateLimitedTask sTrackBottomReachedStats = new RateLimitedTask(2) { + protected boolean run() { + AnalyticsTracker.track(AnalyticsTracker.Stat.STATS_SCROLLED_TO_BOTTOM); + return true; + } + }; +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsAuthorsFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsAuthorsFragment.java new file mode 100644 index 000000000..65b55cc5c --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsAuthorsFragment.java @@ -0,0 +1,282 @@ +package org.wordpress.android.ui.stats; + +import android.app.Activity; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseExpandableListAdapter; + +import org.apache.commons.lang.StringUtils; +import org.wordpress.android.R; +import org.wordpress.android.ui.stats.models.AuthorModel; +import org.wordpress.android.ui.stats.models.AuthorsModel; +import org.wordpress.android.ui.stats.models.FollowDataModel; +import org.wordpress.android.ui.stats.models.PostModel; +import org.wordpress.android.ui.stats.service.StatsService; +import org.wordpress.android.util.FormatUtils; +import org.wordpress.android.util.GravatarUtils; +import org.wordpress.android.widgets.WPNetworkImageView; + +import java.util.List; + +public class StatsAuthorsFragment extends StatsAbstractListFragment { + public static final String TAG = StatsAuthorsFragment.class.getSimpleName(); + + private AuthorsModel mAuthors; + + @Override + protected boolean hasDataAvailable() { + return mAuthors != null; + } + @Override + protected void saveStatsData(Bundle outState) { + if (hasDataAvailable()) { + outState.putSerializable(ARG_REST_RESPONSE, mAuthors); + } + } + @Override + protected void restoreStatsData(Bundle savedInstanceState) { + if (savedInstanceState.containsKey(ARG_REST_RESPONSE)) { + mAuthors = (AuthorsModel) savedInstanceState.getSerializable(ARG_REST_RESPONSE); + } + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.AuthorsUpdated event) { + if (!shouldUpdateFragmentOnUpdateEvent(event)) { + return; + } + + mGroupIdToExpandedMap.clear(); + mAuthors = event.mAuthors; + + updateUI(); + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.SectionUpdateError event) { + if (!shouldUpdateFragmentOnErrorEvent(event)) { + return; + } + + mAuthors = null; + mGroupIdToExpandedMap.clear(); + showErrorUI(event.mError); + } + + @Override + protected void updateUI() { + if (!isAdded()) { + return; + } + + if (!hasAuthors()) { + showHideNoResultsUI(true); + return; + } + + BaseExpandableListAdapter adapter = new MyExpandableListAdapter(getActivity(), mAuthors.getAuthors()); + StatsUIHelper.reloadGroupViews(getActivity(), adapter, mGroupIdToExpandedMap, mList, getMaxNumberOfItemsToShowInList()); + showHideNoResultsUI(false); + } + + private boolean hasAuthors() { + return mAuthors != null + && mAuthors.getAuthors() != null + && mAuthors.getAuthors().size() > 0; + } + + + @Override + protected boolean isViewAllOptionAvailable() { + return (hasAuthors() + && mAuthors.getAuthors().size() > MAX_NUM_OF_ITEMS_DISPLAYED_IN_LIST); + } + + @Override + protected boolean isExpandableList() { + return true; + } + + @Override + protected StatsService.StatsEndpointsEnum[] sectionsToUpdate() { + return new StatsService.StatsEndpointsEnum[]{ + StatsService.StatsEndpointsEnum.AUTHORS + }; + } + + @Override + protected int getEntryLabelResId() { + return R.string.stats_entry_authors; + } + @Override + protected int getTotalsLabelResId() { + return R.string.stats_totals_views; + } + @Override + protected int getEmptyLabelTitleResId() { + return R.string.stats_empty_top_posts_title; + } + @Override + protected int getEmptyLabelDescResId() { + return R.string.stats_empty_top_authors_desc; + } + + private class MyExpandableListAdapter extends BaseExpandableListAdapter { + public final LayoutInflater inflater; + public final Activity activity; + private final List<AuthorModel> authors; + + public MyExpandableListAdapter(Activity act, List<AuthorModel> authors) { + this.activity = act; + this.authors = authors; + this.inflater = act.getLayoutInflater(); + } + + @Override + public Object getChild(int groupPosition, int childPosition) { + AuthorModel currentGroup = authors.get(groupPosition); + List<PostModel> posts = currentGroup.getPosts(); + return posts.get(childPosition); + } + + @Override + public long getChildId(int groupPosition, int childPosition) { + return 0; + } + + @Override + public View getChildView(int groupPosition, final int childPosition, + boolean isLastChild, View convertView, ViewGroup parent) { + + final PostModel children = (PostModel) getChild(groupPosition, childPosition); + + if (convertView == null) { + convertView = inflater.inflate(R.layout.stats_list_cell, parent, false); + // configure view holder + StatsViewHolder viewHolder = new StatsViewHolder(convertView); + convertView.setTag(viewHolder); + } + + final StatsViewHolder holder = (StatsViewHolder) convertView.getTag(); + + // The link icon + holder.showLinkIcon(); + + // name, url + holder.setEntryTextOpenDetailsPage(children); + + // Setup the more button + holder.setMoreButtonOpenInReader(children); + + // totals + int total = children.getTotals(); + holder.totalsTextView.setText(FormatUtils.formatDecimal(total)); + + // no icon + holder.networkImageView.setVisibility(View.GONE); + + return convertView; + } + + @Override + public int getChildrenCount(int groupPosition) { + AuthorModel currentGroup = authors.get(groupPosition); + List<PostModel> posts = currentGroup.getPosts(); + if (posts == null) { + return 0; + } else { + return posts.size(); + } + } + + @Override + public Object getGroup(int groupPosition) { + return authors.get(groupPosition); + } + + @Override + public int getGroupCount() { + return authors.size(); + } + + + @Override + public long getGroupId(int groupPosition) { + return 0; + } + + @Override + public View getGroupView(int groupPosition, boolean isExpanded, + View convertView, ViewGroup parent) { + + if (convertView == null) { + convertView = inflater.inflate(R.layout.stats_list_cell, parent, false); + convertView.setTag(new StatsViewHolder(convertView)); + } + + final StatsViewHolder holder = (StatsViewHolder) convertView.getTag(); + + AuthorModel group = (AuthorModel) getGroup(groupPosition); + + String name = group.getName(); + if (StringUtils.isBlank(name)) { + // Jetpack case: articles published before the activation of Jetpack. + name = getString(R.string.stats_unknown_author); + } + int total = group.getViews(); + String icon = group.getAvatar(); + int children = getChildrenCount(groupPosition); + + holder.setEntryText(name, getResources().getColor(R.color.stats_link_text_color)); + + // totals + holder.totalsTextView.setText(FormatUtils.formatDecimal(total)); + + // icon + //holder.showNetworkImage(icon); + holder.networkImageView.setImageUrl(GravatarUtils.fixGravatarUrl(icon, mResourceVars.headerAvatarSizePx), WPNetworkImageView.ImageType.AVATAR); + holder.networkImageView.setVisibility(View.VISIBLE); + + final FollowDataModel followData = group.getFollowData(); + if (followData == null) { + holder.imgMore.setVisibility(View.GONE); + holder.imgMore.setClickable(false); + } else { + holder.imgMore.setVisibility(View.VISIBLE); + holder.imgMore.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + FollowHelper fh = new FollowHelper(activity); + fh.showPopup(holder.imgMore, followData); + } + }); + } + + if (children == 0) { + holder.showLinkIcon(); + } else { + holder.showChevronIcon(); + } + + return convertView; + } + + @Override + public boolean hasStableIds() { + return false; + } + + @Override + public boolean isChildSelectable(int groupPosition, int childPosition) { + return false; + } + + } + + @Override + public String getTitle() { + return getString(R.string.stats_view_authors); + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsBarGraph.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsBarGraph.java new file mode 100644 index 000000000..342c45821 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsBarGraph.java @@ -0,0 +1,335 @@ +package org.wordpress.android.ui.stats; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.LinearGradient; +import android.graphics.Shader; +import android.support.v4.view.GestureDetectorCompat; +import android.view.GestureDetector; +import android.view.MotionEvent; + +import com.jjoe64.graphview.CustomLabelFormatter; +import com.jjoe64.graphview.GraphView; +import com.jjoe64.graphview.GraphViewDataInterface; +import com.jjoe64.graphview.GraphViewSeries.GraphViewSeriesStyle; +import com.jjoe64.graphview.GraphViewStyle; +import com.jjoe64.graphview.IndexDependentColor; + +import org.wordpress.android.R; +import org.wordpress.android.widgets.TypefaceCache; + +import java.text.NumberFormat; +import java.util.LinkedList; +import java.util.List; + +/** + * A Bar graph depicting the view and visitors. + * Based on BarGraph from the GraphView library. + */ +class StatsBarGraph extends GraphView { + + private static final int DEFAULT_MAX_Y = 10; + + // Keep tracks of every bar drawn on the graph. + private final List<List<BarChartRect>> mSeriesRectsDrawedOnScreen = (List<List<BarChartRect>>) new LinkedList(); + private int mBarPositionToHighlight = -1; + private boolean[] mWeekendDays; + + private final GestureDetectorCompat mDetector; + private OnGestureListener mGestureListener; + + public StatsBarGraph(Context context) { + super(context, ""); + + int width = LayoutParams.MATCH_PARENT; + int height = getResources().getDimensionPixelSize(R.dimen.stats_barchart_height); + setLayoutParams(new LayoutParams(width, height)); + + setProperties(); + + paint.setTypeface(TypefaceCache.getTypeface(getContext())); + + mDetector = new GestureDetectorCompat(getContext(), new MyGestureListener()); + mDetector.setIsLongpressEnabled(false); + } + + public void setGestureListener(OnGestureListener listener) { + this.mGestureListener = listener; + } + + class MyGestureListener extends GestureDetector.SimpleOnGestureListener { + @Override + public boolean onDown(MotionEvent event) { + return true; + } + + @Override + public boolean onSingleTapUp(MotionEvent event) { + highlightBarAndBroadcastDate(); + return false; + } + + @Override + public void onShowPress(MotionEvent e) { + highlightBarAndBroadcastDate(); + } + + @Override + public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { + return false; + } + + @Override + public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { + return false; + } + + private void highlightBarAndBroadcastDate() { + int tappedBar = getTappedBar(); + //AppLog.d(AppLog.T.STATS, this.getClass().getName() + " Tapped bar " + tappedBar); + if (tappedBar >= 0) { + highlightBar(tappedBar); + if (mGestureListener != null) { + mGestureListener.onBarTapped(tappedBar); + } + } + } + } + + @Override + public boolean onTouchEvent (MotionEvent event) { + boolean handled = super.onTouchEvent(event); + if (mDetector != null && handled) { + this.mDetector.onTouchEvent(event); + } + return handled; + } + + private class HorizontalLabelsColor implements IndexDependentColor { + public int get(int index) { + if (mBarPositionToHighlight == index) { + return getResources().getColor(R.color.orange_jazzy); + } else { + return getResources().getColor(R.color.grey_darken_30); + } + } + } + + private void setProperties() { + GraphViewStyle gStyle = getGraphViewStyle(); + gStyle.setHorizontalLabelsIndexDependentColor(new HorizontalLabelsColor()); + gStyle.setHorizontalLabelsColor(getResources().getColor(R.color.grey_darken_30)); + gStyle.setVerticalLabelsColor(getResources().getColor(R.color.grey_darken_10)); + gStyle.setTextSize(getResources().getDimensionPixelSize(R.dimen.text_sz_extra_small)); + gStyle.setGridXColor(Color.TRANSPARENT); + gStyle.setGridYColor(getResources().getColor(R.color.grey_lighten_30)); + gStyle.setNumVerticalLabels(3); + + setCustomLabelFormatter(new CustomLabelFormatter() { + private NumberFormat numberFormatter; + + @Override + public String formatLabel(double value, boolean isValueX) { + if (isValueX) { + return null; + } + if (numberFormatter == null) { + numberFormatter = NumberFormat.getNumberInstance(); + numberFormatter.setMaximumFractionDigits(0); + } + return numberFormatter.format(value); + } + }); + } + + @Override + protected void onBeforeDrawSeries() { + mSeriesRectsDrawedOnScreen.clear(); + } + + @Override + public void drawSeries(Canvas canvas, GraphViewDataInterface[] values, + float graphwidth, float graphheight, float border, double minX, + double minY, double diffX, double diffY, float horstart, + GraphViewSeriesStyle style) { + float colwidth = graphwidth / values.length; + int maxColumnSize = getGraphViewStyle().getMaxColumnWidth(); + if (maxColumnSize > 0 && colwidth > maxColumnSize) { + colwidth = maxColumnSize; + } + + paint.setStrokeWidth(style.thickness); + paint.setColor(style.color); + + // Bar chart position of this series on the canvas + List<BarChartRect> barChartRects = new LinkedList<>(); + + // draw data + for (int i = 0; i < values.length; i++) { + float valY = (float) (values[i].getY() - minY); + float ratY = (float) (valY / diffY); + float y = graphheight * ratY; + + // hook for value dependent color + if (style.getValueDependentColor() != null) { + paint.setColor(style.getValueDependentColor().get(values[i])); + } + + float pad = style.padding; + + float left = (i * colwidth) + horstart; + float top = (border - y) + graphheight; + float right = left + colwidth; + float bottom = graphheight + border - 1; + + // Draw the orange selection behind the selected bar + if (style.outerhighlightColor != 0x00ffffff && mBarPositionToHighlight == i) { + paint.setColor(style.outerhighlightColor); + canvas.drawRect(left, 10f, right, bottom, paint); + } + + // Draw the grey background color on weekend days + if (style.outerColor != 0x00ffffff + && mBarPositionToHighlight != i + && mWeekendDays != null && mWeekendDays[i]) { + paint.setColor(style.outerColor); + canvas.drawRect(left, 10f, right, bottom, paint); + } + + if ((top - bottom) == 1) { + // draw a placeholder + if (mBarPositionToHighlight != i) { + paint.setColor(style.color); + paint.setAlpha(25); + Shader shader = new LinearGradient(left + pad, bottom - 50, left + pad, bottom, Color.WHITE, Color.BLACK, Shader.TileMode.CLAMP); + paint.setShader(shader); + canvas.drawRect(left + pad, bottom - 50, right - pad, bottom, paint); + paint.setShader(null); + } + } else { + // draw a real bar + paint.setAlpha(255); + if (mBarPositionToHighlight == i) { + paint.setColor(style.highlightColor); + } else { + paint.setColor(style.color); + } + canvas.drawRect(left + pad, top, right - pad, bottom, paint); + } + + barChartRects.add(new BarChartRect(left + pad, top, right - pad, bottom)); + } + mSeriesRectsDrawedOnScreen.add(barChartRects); + } + + private int getTappedBar() { + float[] lastBarChartTouchedPoint = this.getLastTouchedPointOnCanvasAndReset(); + if (lastBarChartTouchedPoint[0] == 0f && lastBarChartTouchedPoint[1] == 0f) { + return -1; + } + for (List<BarChartRect> currentSerieChartRects : mSeriesRectsDrawedOnScreen) { + int i = 0; + for (BarChartRect barChartRect : currentSerieChartRects) { + if (barChartRect.isPointInside(lastBarChartTouchedPoint[0], lastBarChartTouchedPoint[1])) { + return i; + } + i++; + } + } + return -1; + } +/* + public float getMiddlePointOfTappedBar(int tappedBar) { + if (tappedBar == -1 || mSeriesRectsDrawedOnScreen == null || mSeriesRectsDrawedOnScreen.size() == 0) { + return -1; + } + BarChartRect rect = mSeriesRectsDrawedOnScreen.get(0).get(tappedBar); + + return ((rect.mLeft + rect.mRight) / 2) + getCanvasLeft(); + } + + public void highlightAndDismissBar(int barPosition) { + mBarPositionToHighlight = barPosition; + if (mBarPositionToHighlight == -1) { + return; + } + this.redrawAll(); + final Handler handler = new Handler(); + handler.postDelayed(new Runnable() { + @Override + public void run() { + mBarPositionToHighlight = -1; + redrawAll(); + } + }, 500); + } +*/ + + public void setWeekendDays(boolean[] days) { + mWeekendDays = days; + } + + public void highlightBar(int barPosition) { + mBarPositionToHighlight = barPosition; + this.redrawAll(); + } + + public int getHighlightBar() { + return mBarPositionToHighlight; + } + + public void resetHighlightBar() { + mBarPositionToHighlight = -1; + } + + @Override + protected double getMinY() { + return 0; + } + + // Make sure the highest number is always even, so the halfway mark is correctly balanced in the middle of the graph + // Also make sure to display a default value when there is no activity in the period. + @Override + protected double getMaxY() { + double maxY = super.getMaxY(); + if (maxY == 0) { + return DEFAULT_MAX_Y; + } + + return maxY + (maxY % 2); + } + + /** + * Private class that is used to hold the local (to the canvas) coordinate on the screen + * of every single bar in the graph + */ + private class BarChartRect { + final float mLeft; + final float mTop; + final float mRight; + final float mBottom; + + BarChartRect(float left, float top, float right, float bottom) { + this.mLeft = left; + this.mTop = top; + this.mRight = right; + this.mBottom = bottom; + } + + /** + * Check if the tap happens on a bar in the graph. + * + * @return true if the tap point falls within the bar for the X coordinate, and within the full canvas + * height for the Y coordinate. This is a fix to make very small bars tappable. + */ + public boolean isPointInside(float x, float y) { + return x >= this.mLeft + && x <= this.mRight; + } + } + + interface OnGestureListener { + void onBarTapped(int tappedBar); + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsClicksFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsClicksFragment.java new file mode 100644 index 000000000..edb4ef953 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsClicksFragment.java @@ -0,0 +1,266 @@ +package org.wordpress.android.ui.stats; + +import android.content.Context; +import android.os.Bundle; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseExpandableListAdapter; + +import org.wordpress.android.R; +import org.wordpress.android.ui.stats.models.ClickGroupModel; +import org.wordpress.android.ui.stats.models.ClicksModel; +import org.wordpress.android.ui.stats.models.SingleItemModel; +import org.wordpress.android.ui.stats.service.StatsService; +import org.wordpress.android.util.FormatUtils; +import org.wordpress.android.util.GravatarUtils; +import org.wordpress.android.widgets.WPNetworkImageView; + +import java.util.List; + +public class StatsClicksFragment extends StatsAbstractListFragment { + public static final String TAG = StatsClicksFragment.class.getSimpleName(); + + private ClicksModel mClicks; + + @Override + protected boolean hasDataAvailable() { + return mClicks != null; + } + @Override + protected void saveStatsData(Bundle outState) { + if (hasDataAvailable()) { + outState.putSerializable(ARG_REST_RESPONSE, mClicks); + } + } + @Override + protected void restoreStatsData(Bundle savedInstanceState) { + if (savedInstanceState.containsKey(ARG_REST_RESPONSE)) { + mClicks = (ClicksModel) savedInstanceState.getSerializable(ARG_REST_RESPONSE); + } + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.ClicksUpdated event) { + if (!shouldUpdateFragmentOnUpdateEvent(event)) { + return; + } + + mGroupIdToExpandedMap.clear(); + mClicks = event.mClicks; + + updateUI(); + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.SectionUpdateError event) { + if (!shouldUpdateFragmentOnErrorEvent(event)) { + return; + } + + mClicks = null; + mGroupIdToExpandedMap.clear(); + showErrorUI(event.mError); + } + + @Override + protected void updateUI() { + if (!isAdded()) { + return; + } + + if (hasClicks()) { + BaseExpandableListAdapter adapter = new MyExpandableListAdapter(getActivity(), mClicks.getClickGroups()); + StatsUIHelper.reloadGroupViews(getActivity(), adapter, mGroupIdToExpandedMap, mList, getMaxNumberOfItemsToShowInList()); + showHideNoResultsUI(false); + } else { + showHideNoResultsUI(true); + } + } + + private boolean hasClicks() { + return mClicks != null + && mClicks.getClickGroups() != null + && mClicks.getClickGroups().size() > 0; + } + + @Override + protected boolean isViewAllOptionAvailable() { + return (hasClicks() && mClicks.getClickGroups().size() > MAX_NUM_OF_ITEMS_DISPLAYED_IN_LIST); + } + + @Override + protected boolean isExpandableList() { + return true; + } + + @Override + protected StatsService.StatsEndpointsEnum[] sectionsToUpdate() { + return new StatsService.StatsEndpointsEnum[]{ + StatsService.StatsEndpointsEnum.CLICKS + }; + } + + @Override + protected int getEntryLabelResId() { + return R.string.stats_entry_clicks_link; + } + @Override + protected int getTotalsLabelResId() { + return R.string.stats_totals_clicks; + } + @Override + protected int getEmptyLabelTitleResId() { + return R.string.stats_empty_clicks_title; + } + @Override + protected int getEmptyLabelDescResId() { + return R.string.stats_empty_clicks_desc; + } + + private class MyExpandableListAdapter extends BaseExpandableListAdapter { + public final LayoutInflater inflater; + private final List<ClickGroupModel> clickGroups; + + public MyExpandableListAdapter(Context context, List<ClickGroupModel> clickGroups) { + this.clickGroups = clickGroups; + this.inflater = LayoutInflater.from(context); + } + + @Override + public Object getChild(int groupPosition, int childPosition) { + ClickGroupModel currentGroup = clickGroups.get(groupPosition); + List<SingleItemModel> results = currentGroup.getClicks(); + return results.get(childPosition); + } + + @Override + public long getChildId(int groupPosition, int childPosition) { + return 0; + } + + @Override + public View getChildView(int groupPosition, final int childPosition, + boolean isLastChild, View convertView, ViewGroup parent) { + + final SingleItemModel children = (SingleItemModel) getChild(groupPosition, childPosition); + + if (convertView == null) { + convertView = inflater.inflate(R.layout.stats_list_cell, parent, false); + // configure view holder + StatsViewHolder viewHolder = new StatsViewHolder(convertView); + convertView.setTag(viewHolder); + } + + final StatsViewHolder holder = (StatsViewHolder) convertView.getTag(); + + // The link icon + holder.showLinkIcon(); + + // name, url + holder.setEntryTextOrLink(children.getUrl(), children.getTitle()); + + // totals + holder.totalsTextView.setText(FormatUtils.formatDecimal( + children.getTotals() + )); + + // no icon + holder.networkImageView.setVisibility(View.GONE); + + return convertView; + } + + @Override + public int getChildrenCount(int groupPosition) { + ClickGroupModel currentGroup = clickGroups.get(groupPosition); + List<SingleItemModel> clicks = currentGroup.getClicks(); + if (clicks == null) { + return 0; + } else { + return clicks.size(); + } + } + + @Override + public Object getGroup(int groupPosition) { + return clickGroups.get(groupPosition); + } + + @Override + public int getGroupCount() { + return clickGroups.size(); + } + + + @Override + public long getGroupId(int groupPosition) { + return 0; + } + + @Override + public View getGroupView(int groupPosition, boolean isExpanded, + View convertView, ViewGroup parent) { + + final StatsViewHolder holder; + if (convertView == null) { + convertView = inflater.inflate(R.layout.stats_list_cell, parent, false); + holder = new StatsViewHolder(convertView); + convertView.setTag(holder); + } else { + holder = (StatsViewHolder) convertView.getTag(); + } + + ClickGroupModel group = (ClickGroupModel) getGroup(groupPosition); + + String name = group.getName(); + int total = group.getViews(); + String url = group.getUrl(); + String icon = group.getIcon(); + int children = getChildrenCount(groupPosition); + + if (children > 0) { + holder.setEntryText(name, getResources().getColor(R.color.stats_link_text_color)); + } else { + holder.setEntryTextOrLink(url, name); + } + + // totals + holder.totalsTextView.setText(FormatUtils.formatDecimal(total)); + + // Site icon + holder.networkImageView.setVisibility(View.GONE); + if (!TextUtils.isEmpty(icon)) { + holder.networkImageView.setImageUrl( + GravatarUtils.fixGravatarUrl(icon, mResourceVars.headerAvatarSizePx), + WPNetworkImageView.ImageType.GONE_UNTIL_AVAILABLE + ); + } + + if (children == 0) { + holder.showLinkIcon(); + } else { + holder.showChevronIcon(); + } + + return convertView; + } + + @Override + public boolean hasStableIds() { + return false; + } + + @Override + public boolean isChildSelectable(int groupPosition, int childPosition) { + return false; + } + + } + + @Override + public String getTitle() { + return getString(R.string.stats_view_clicks); + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsCommentsFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsCommentsFragment.java new file mode 100644 index 000000000..bb13d37f3 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsCommentsFragment.java @@ -0,0 +1,280 @@ +package org.wordpress.android.ui.stats; + +import android.app.Activity; +import android.content.res.Resources; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; + +import org.wordpress.android.R; +import org.wordpress.android.ui.stats.adapters.PostsAndPagesAdapter; +import org.wordpress.android.ui.stats.models.AuthorModel; +import org.wordpress.android.ui.stats.models.CommentFollowersModel; +import org.wordpress.android.ui.stats.models.CommentsModel; +import org.wordpress.android.ui.stats.models.FollowDataModel; +import org.wordpress.android.ui.stats.models.PostModel; +import org.wordpress.android.ui.stats.service.StatsService; +import org.wordpress.android.util.FormatUtils; +import org.wordpress.android.util.GravatarUtils; +import org.wordpress.android.widgets.WPNetworkImageView; + +import java.util.ArrayList; +import java.util.List; + + +public class StatsCommentsFragment extends StatsAbstractListFragment { + public static final String TAG = StatsCommentsFragment.class.getSimpleName(); + static final String ARG_REST_RESPONSE_FOLLOWERS = "ARG_REST_RESPONSE_FOLLOWERS"; + + private CommentsModel mCommentsModel; + private CommentFollowersModel mCommentFollowersModel; + + @Override + protected boolean hasDataAvailable() { + return mCommentsModel != null && mCommentFollowersModel != null; + } + @Override + protected void saveStatsData(Bundle outState) { + if (mCommentsModel != null) { + outState.putSerializable(ARG_REST_RESPONSE, mCommentsModel); + } + if (mCommentFollowersModel != null) { + outState.putSerializable(ARG_REST_RESPONSE_FOLLOWERS, mCommentFollowersModel); + } + } + @Override + protected void restoreStatsData(Bundle savedInstanceState) { + if (savedInstanceState.containsKey(ARG_REST_RESPONSE)) { + mCommentsModel = (CommentsModel) savedInstanceState.getSerializable(ARG_REST_RESPONSE); + } + if (savedInstanceState.containsKey(ARG_REST_RESPONSE_FOLLOWERS)) { + mCommentFollowersModel = (CommentFollowersModel) savedInstanceState.getSerializable(ARG_REST_RESPONSE_FOLLOWERS); + } + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.CommentsUpdated event) { + if (!shouldUpdateFragmentOnUpdateEvent(event)) { + return; + } + + mCommentsModel = event.mComments; + updateUI(); + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.CommentFollowersUpdated event) { + if (!shouldUpdateFragmentOnUpdateEvent(event)) { + return; + } + + mCommentFollowersModel = event.mCommentFollowers; + updateUI(); + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.SectionUpdateError event) { + if (!shouldUpdateFragmentOnErrorEvent(event)) { + return; + } + + mCommentsModel = null; + mCommentFollowersModel = null; + showErrorUI(event.mError); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View view = super.onCreateView(inflater, container, savedInstanceState); + + Resources res = container.getContext().getResources(); + String[] titles = { + res.getString(R.string.stats_comments_by_authors), + res.getString(R.string.stats_comments_by_posts_and_pages), + }; + + setupTopModulePager(inflater, container, view, titles); + + return view; + } + + @Override + protected void updateUI() { + // This module is a kind of exception to the normal way we build page interface. + // In this module only the first rest endpoint StatsService.StatsEndpointsEnum.COMMENTS + // is used to populate 99% of the UI even if there is a tab on the top. + // Switching to a different tab on the UI doesn't switch the underlying datamodel index as in all other modules. + + if (!isAdded()) { + return; + } + + if (mCommentsModel == null && mCommentFollowersModel == null) { + showHideNoResultsUI(true); + mTotalsLabel.setVisibility(View.GONE); + mTopPagerContainer.setVisibility(View.GONE); + return; + } + + mTopPagerContainer.setVisibility(View.VISIBLE); + + if (mCommentFollowersModel != null) { // check if comment-followers is already here + mTotalsLabel.setVisibility(View.VISIBLE); + int totalNumberOfFollowers = mCommentFollowersModel.getTotal(); + String totalCommentsFollowers = getString(R.string.stats_comments_total_comments_followers); + mTotalsLabel.setText( + String.format(totalCommentsFollowers, FormatUtils.formatDecimal(totalNumberOfFollowers)) + ); + } + + ArrayAdapter adapter = null; + + if (mTopPagerSelectedButtonIndex == 0 && hasAuthors()) { + adapter = new AuthorsAdapter(getActivity(), getAuthors()); + } else if (mTopPagerSelectedButtonIndex == 1 && hasPosts()) { + adapter = new PostsAndPagesAdapter(getActivity(), getPosts()); + } + + if (adapter != null) { + StatsUIHelper.reloadLinearLayout(getActivity(), adapter, mList, getMaxNumberOfItemsToShowInList()); + showHideNoResultsUI(false); + } else { + showHideNoResultsUI(true); + } + } + + private boolean hasAuthors() { + return mCommentsModel != null + && mCommentsModel.getAuthors() != null + && mCommentsModel.getAuthors().size() > 0; + } + + private List<AuthorModel> getAuthors() { + if (!hasAuthors()) { + return new ArrayList<AuthorModel>(0); + } + return mCommentsModel.getAuthors(); + } + + private boolean hasPosts() { + return mCommentsModel != null + && mCommentsModel.getPosts() != null + && mCommentsModel.getPosts().size() > 0; + } + + private List<PostModel> getPosts() { + if (!hasPosts()) { + return new ArrayList<PostModel>(0); + } + return mCommentsModel.getPosts(); + } + + @Override + protected boolean isViewAllOptionAvailable() { + if (mTopPagerSelectedButtonIndex == 0 && hasAuthors() && getAuthors().size() > MAX_NUM_OF_ITEMS_DISPLAYED_IN_LIST) { + return true; + } else if (mTopPagerSelectedButtonIndex == 1 && hasPosts() && getPosts().size() > MAX_NUM_OF_ITEMS_DISPLAYED_IN_LIST) { + return true; + } + return false; + } + + @Override + protected boolean isExpandableList() { + return false; + } + + private class AuthorsAdapter extends ArrayAdapter<AuthorModel> { + + private final List<AuthorModel> list; + private final Activity context; + private final LayoutInflater inflater; + + public AuthorsAdapter(Activity context, List<AuthorModel> list) { + super(context, R.layout.stats_list_cell, list); + this.context = context; + this.list = list; + inflater = LayoutInflater.from(context); + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + View rowView = convertView; + // reuse views + if (rowView == null) { + rowView = inflater.inflate(R.layout.stats_list_cell, parent, false); + // configure view holder + StatsViewHolder viewHolder = new StatsViewHolder(rowView); + rowView.setTag(viewHolder); + } + + final AuthorModel currentRowData = list.get(position); + final StatsViewHolder holder = (StatsViewHolder) rowView.getTag(); + + // entries + holder.setEntryText(currentRowData.getName(), getResources().getColor(R.color.stats_text_color)); + + // totals + holder.totalsTextView.setText(FormatUtils.formatDecimal(currentRowData.getViews())); + + // avatar + holder.networkImageView.setImageUrl(GravatarUtils.fixGravatarUrl(currentRowData.getAvatar(), mResourceVars.headerAvatarSizePx), WPNetworkImageView.ImageType.AVATAR); + holder.networkImageView.setVisibility(View.VISIBLE); + + final FollowDataModel followData = currentRowData.getFollowData(); + if (followData == null) { + holder.imgMore.setVisibility(View.GONE); + holder.imgMore.setClickable(false); + } else { + holder.imgMore.setVisibility(View.VISIBLE); + holder.imgMore.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + FollowHelper fh = new FollowHelper(context); + fh.showPopup(holder.imgMore, followData); + } + }); + } + + return rowView; + } + } + + @Override + protected int getEntryLabelResId() { + if (mTopPagerSelectedButtonIndex == 0) { + return R.string.stats_entry_top_commenter; + } else { + return R.string.stats_entry_posts_and_pages; + } + } + + @Override + protected int getTotalsLabelResId() { + return R.string.stats_totals_comments; + } + + @Override + protected int getEmptyLabelTitleResId() { + return R.string.stats_empty_comments; + } + + @Override + protected int getEmptyLabelDescResId() { + return R.string.stats_empty_comments_desc; + } + + @Override + protected StatsService.StatsEndpointsEnum[] sectionsToUpdate() { + return new StatsService.StatsEndpointsEnum[]{ + StatsService.StatsEndpointsEnum.COMMENTS, StatsService.StatsEndpointsEnum.COMMENT_FOLLOWERS + }; + } + + @Override + public String getTitle() { + return getString(R.string.stats_view_comments); + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsConstants.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsConstants.java new file mode 100644 index 000000000..fd1a456d8 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsConstants.java @@ -0,0 +1,21 @@ +package org.wordpress.android.ui.stats; + +public class StatsConstants { + + // Date formatting constants + public static final String STATS_INPUT_DATE_FORMAT = "yyyy-MM-dd"; + public static final String STATS_OUTPUT_DATE_MONTH_SHORT_DAY_SHORT_FORMAT = "MMM d"; + public static final String STATS_OUTPUT_DATE_MONTH_LONG_DAY_SHORT_FORMAT = "MMMM d"; + public static final String STATS_OUTPUT_DATE_MONTH_LONG_DAY_LONG_FORMAT = "MMMM dd"; + public static final String STATS_OUTPUT_DATE_MONTH_LONG_FORMAT = "MMMM"; + public static final String STATS_OUTPUT_DATE_YEAR_FORMAT = "yyyy"; + + public static final int STATS_GRAPH_BAR_MAX_COLUMN_WIDTH_DP = 100; + + public static final long STATS_SCROLL_TO_DELAY = 75L; + public static final long STATS_LABELS_SETUP_DELAY = 75L; + + public static final String ITEM_TYPE_POST = "post"; + public static final String ITEM_TYPE_PAGE = "page"; + public static final String ITEM_TYPE_HOME_PAGE = "homepage"; +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsEvents.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsEvents.java new file mode 100644 index 000000000..b5b604303 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsEvents.java @@ -0,0 +1,276 @@ +package org.wordpress.android.ui.stats; + +import com.android.volley.VolleyError; + +import org.wordpress.android.ui.stats.models.AuthorsModel; +import org.wordpress.android.ui.stats.models.ClicksModel; +import org.wordpress.android.ui.stats.models.CommentFollowersModel; +import org.wordpress.android.ui.stats.models.CommentsModel; +import org.wordpress.android.ui.stats.models.FollowersModel; +import org.wordpress.android.ui.stats.models.GeoviewsModel; +import org.wordpress.android.ui.stats.models.InsightsAllTimeModel; +import org.wordpress.android.ui.stats.models.InsightsLatestPostDetailsModel; +import org.wordpress.android.ui.stats.models.InsightsLatestPostModel; +import org.wordpress.android.ui.stats.models.InsightsPopularModel; +import org.wordpress.android.ui.stats.models.PublicizeModel; +import org.wordpress.android.ui.stats.models.ReferrersModel; +import org.wordpress.android.ui.stats.models.SearchTermsModel; +import org.wordpress.android.ui.stats.models.TagsContainerModel; +import org.wordpress.android.ui.stats.models.TopPostsAndPagesModel; +import org.wordpress.android.ui.stats.models.VideoPlaysModel; +import org.wordpress.android.ui.stats.models.VisitsModel; +import org.wordpress.android.ui.stats.service.StatsService.StatsEndpointsEnum; + +public class StatsEvents { + public static class UpdateStatusChanged { + public final boolean mUpdating; + public UpdateStatusChanged(boolean updating) { + mUpdating = updating; + } + } + + public abstract static class SectionUpdatedAbstract { + public final String mRequestBlogId; // This is the remote blog ID + public final StatsTimeframe mTimeframe; + public final String mDate; + public final int mMaxResultsRequested, mPageRequested; + + public SectionUpdatedAbstract(String blogId, StatsTimeframe timeframe, String date, + final int maxResultsRequested, final int pageRequested) { + mRequestBlogId = blogId; + mDate = date; + mTimeframe = timeframe; + mMaxResultsRequested = maxResultsRequested; + mPageRequested = pageRequested; + } + } + + public static class SectionUpdateError extends SectionUpdatedAbstract { + + public final VolleyError mError; + public final StatsEndpointsEnum mEndPointName; + + public SectionUpdateError(StatsEndpointsEnum endPointName, String blogId, StatsTimeframe timeframe, String date, + final int maxResultsRequested, final int pageRequested, VolleyError error) { + super(blogId, timeframe, date, maxResultsRequested, pageRequested); + mEndPointName = endPointName; + mError = error; + } + } + + public static class VisitorsAndViewsUpdated extends SectionUpdatedAbstract { + + public final VisitsModel mVisitsAndViews; + + public VisitorsAndViewsUpdated(String blogId, StatsTimeframe timeframe, String date, + final int maxResultsRequested, final int pageRequested, VisitsModel responseObjectModel) { + super(blogId, timeframe, date, maxResultsRequested, pageRequested); + mVisitsAndViews = responseObjectModel; + } + } + + public static class TopPostsUpdated extends SectionUpdatedAbstract { + + public final TopPostsAndPagesModel mTopPostsAndPagesModel; + + public TopPostsUpdated(String blogId, StatsTimeframe timeframe, String date, + final int maxResultsRequested, final int pageRequested, TopPostsAndPagesModel responseObjectModel) { + super(blogId, timeframe, date, maxResultsRequested, pageRequested); + mTopPostsAndPagesModel = responseObjectModel; + } + } + + public static class ReferrersUpdated extends SectionUpdatedAbstract { + + public final ReferrersModel mReferrers; + + public ReferrersUpdated(String blogId, StatsTimeframe timeframe, String date, + final int maxResultsRequested, final int pageRequested, ReferrersModel responseObjectModel) { + super(blogId, timeframe, date, maxResultsRequested, pageRequested); + mReferrers = responseObjectModel; + } + } + + public static class ClicksUpdated extends SectionUpdatedAbstract { + + public final ClicksModel mClicks; + + public ClicksUpdated(String blogId, StatsTimeframe timeframe, String date, + final int maxResultsRequested, final int pageRequested, ClicksModel responseObjectModel) { + super(blogId, timeframe, date, maxResultsRequested, pageRequested); + mClicks = responseObjectModel; + } + } + + + public static class AuthorsUpdated extends SectionUpdatedAbstract { + + public final AuthorsModel mAuthors; + + public AuthorsUpdated(String blogId, StatsTimeframe timeframe, String date, + final int maxResultsRequested, final int pageRequested, AuthorsModel responseObjectModel) { + super(blogId, timeframe, date, maxResultsRequested, pageRequested); + mAuthors = responseObjectModel; + } + } + + public static class CountriesUpdated extends SectionUpdatedAbstract { + + public final GeoviewsModel mCountries; + + public CountriesUpdated(String blogId, StatsTimeframe timeframe, String date, + final int maxResultsRequested, final int pageRequested, GeoviewsModel responseObjectModel) { + super(blogId, timeframe, date, maxResultsRequested, pageRequested); + mCountries = responseObjectModel; + } + } + + public static class VideoPlaysUpdated extends SectionUpdatedAbstract { + + public final VideoPlaysModel mVideos; + + public VideoPlaysUpdated(String blogId, StatsTimeframe timeframe, String date, + final int maxResultsRequested, final int pageRequested, VideoPlaysModel responseObjectModel) { + super(blogId, timeframe, date, maxResultsRequested, pageRequested); + mVideos = responseObjectModel; + } + } + + public static class SearchTermsUpdated extends SectionUpdatedAbstract { + + public final SearchTermsModel mSearchTerms; + + public SearchTermsUpdated(String blogId, StatsTimeframe timeframe, String date, + final int maxResultsRequested, final int pageRequested, SearchTermsModel responseObjectModel) { + super(blogId, timeframe, date, maxResultsRequested, pageRequested); + mSearchTerms = responseObjectModel; + } + } + + public static class CommentsUpdated extends SectionUpdatedAbstract { + + public final CommentsModel mComments; + + public CommentsUpdated(String blogId, StatsTimeframe timeframe, String date, + final int maxResultsRequested, final int pageRequested, CommentsModel responseObjectModel) { + super(blogId, timeframe, date, maxResultsRequested, pageRequested); + mComments = responseObjectModel; + } + } + + public static class CommentFollowersUpdated extends SectionUpdatedAbstract { + + public final CommentFollowersModel mCommentFollowers; + + public CommentFollowersUpdated(String blogId, StatsTimeframe timeframe, String date, + final int maxResultsRequested, final int pageRequested, CommentFollowersModel responseObjectModel) { + super(blogId, timeframe, date, maxResultsRequested, pageRequested); + mCommentFollowers = responseObjectModel; + } + } + + public static class TagsUpdated extends SectionUpdatedAbstract { + + public final TagsContainerModel mTagsContainer; + + public TagsUpdated(String blogId, StatsTimeframe timeframe, String date, + final int maxResultsRequested, final int pageRequested, TagsContainerModel responseObjectModel) { + super(blogId, timeframe, date, maxResultsRequested, pageRequested); + mTagsContainer = responseObjectModel; + } + } + + public static class PublicizeUpdated extends SectionUpdatedAbstract { + + public final PublicizeModel mPublicizeModel; + + public PublicizeUpdated(String blogId, StatsTimeframe timeframe, String date, + final int maxResultsRequested, final int pageRequested, PublicizeModel responseObjectModel) { + super(blogId, timeframe, date, maxResultsRequested, pageRequested); + mPublicizeModel = responseObjectModel; + } + } + + public static class FollowersWPCOMUdated extends SectionUpdatedAbstract { + + public final FollowersModel mFollowers; + + public FollowersWPCOMUdated(String blogId, StatsTimeframe timeframe, String date, + final int maxResultsRequested, final int pageRequested, FollowersModel responseObjectModel) { + super(blogId, timeframe, date, maxResultsRequested, pageRequested); + mFollowers = responseObjectModel; + } + } + + public static class FollowersEmailUdated extends SectionUpdatedAbstract { + + public final FollowersModel mFollowers; + + public FollowersEmailUdated(String blogId, StatsTimeframe timeframe, String date, + final int maxResultsRequested, final int pageRequested, FollowersModel responseObjectModel) { + super(blogId, timeframe, date, maxResultsRequested, pageRequested); + mFollowers = responseObjectModel; + } + } + + public static class InsightsAllTimeUpdated extends SectionUpdatedAbstract { + + public final InsightsAllTimeModel mInsightsAllTimeModel; + + public InsightsAllTimeUpdated(String blogId, StatsTimeframe timeframe, String date, + final int maxResultsRequested, final int pageRequested, InsightsAllTimeModel responseObjectModel) { + super(blogId, timeframe, date, maxResultsRequested, pageRequested); + mInsightsAllTimeModel = responseObjectModel; + } + } + + public static class InsightsPopularUpdated extends SectionUpdatedAbstract { + + public final InsightsPopularModel mInsightsPopularModel; + + public InsightsPopularUpdated(String blogId, StatsTimeframe timeframe, String date, + final int maxResultsRequested, final int pageRequested, InsightsPopularModel responseObjectModel) { + super(blogId, timeframe, date, maxResultsRequested, pageRequested); + mInsightsPopularModel = responseObjectModel; + } + } + + public static class InsightsLatestPostSummaryUpdated extends SectionUpdatedAbstract { + + public final InsightsLatestPostModel mInsightsLatestPostModel; + + public InsightsLatestPostSummaryUpdated(String blogId, StatsTimeframe timeframe, String date, + final int maxResultsRequested, final int pageRequested, + InsightsLatestPostModel responseObjectModel) { + super(blogId, timeframe, date, maxResultsRequested, pageRequested); + mInsightsLatestPostModel = responseObjectModel; + } + } + + public static class InsightsLatestPostDetailsUpdated extends SectionUpdatedAbstract { + + public final InsightsLatestPostDetailsModel mInsightsLatestPostDetailsModel; + + public InsightsLatestPostDetailsUpdated(String blogId, StatsTimeframe timeframe, String date, + final int maxResultsRequested, final int pageRequested, + InsightsLatestPostDetailsModel responseObjectModel) { + super(blogId, timeframe, date, maxResultsRequested, pageRequested); + mInsightsLatestPostDetailsModel = responseObjectModel; + } + } + + public static class JetpackSettingsCompleted { + public final boolean isError; + public JetpackSettingsCompleted(boolean isError) { + this.isError = isError; + } + } + + public static class JetpackAuthError { + public final int mLocalBlogId; // This is the local blogID + + public JetpackAuthError(int blogId) { + mLocalBlogId = blogId; + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsFollowersFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsFollowersFragment.java new file mode 100644 index 000000000..dc15eab17 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsFollowersFragment.java @@ -0,0 +1,449 @@ +package org.wordpress.android.ui.stats; + +import android.app.Activity; +import android.content.res.Resources; +import android.os.Bundle; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.LinearLayout; + +import org.wordpress.android.R; +import org.wordpress.android.WordPress; +import org.wordpress.android.ui.reader.ReaderActivityLauncher; +import org.wordpress.android.ui.stats.models.FollowDataModel; +import org.wordpress.android.ui.stats.models.FollowerModel; +import org.wordpress.android.ui.stats.models.FollowersModel; +import org.wordpress.android.ui.stats.service.StatsService; +import org.wordpress.android.util.DisplayUtils; +import org.wordpress.android.util.FormatUtils; +import org.wordpress.android.util.GravatarUtils; +import org.wordpress.android.util.UrlUtils; +import org.wordpress.android.widgets.WPNetworkImageView; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadPoolExecutor; + + +public class StatsFollowersFragment extends StatsAbstractListFragment { + public static final String TAG = StatsFollowersFragment.class.getSimpleName(); + + private static final String ARG_REST_RESPONSE_FOLLOWERS_EMAIL = "ARG_REST_RESPONSE_FOLLOWERS_EMAIL"; + private final Map<String, Integer> userBlogs = new HashMap<>(); + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View view = super.onCreateView(inflater, container, savedInstanceState); + + Resources res = container.getContext().getResources(); + + String[] titles = { + res.getString(R.string.stats_followers_wpcom_selector), + res.getString(R.string.stats_followers_email_selector), + }; + + + setupTopModulePager(inflater, container, view, titles); + + mTopPagerContainer.setVisibility(View.VISIBLE); + mTotalsLabel.setVisibility(View.VISIBLE); + mTotalsLabel.setText(""); + + return view; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // Single background thread used to create the blogs list in BG + ThreadPoolExecutor blogsListCreatorExecutor = (ThreadPoolExecutor) Executors.newFixedThreadPool(1); + blogsListCreatorExecutor.submit(new Thread() { + @Override + public void run() { + // Read all the dotcomBlog blogs and get the list of home URLs. + // This will be used later to check if the user is a member of followers blog marked as private. + List<Map<String, Object>> dotComUserBlogs = WordPress.wpDB.getBlogsBy("dotcomFlag=1", + new String[]{"homeURL"}); + for (Map<String, Object> blog : dotComUserBlogs) { + if (blog != null && blog.get("homeURL") != null && blog.get("blogId") != null) { + String normURL = normalizeAndRemoveScheme(blog.get("homeURL").toString()); + Integer blogID = (Integer) blog.get("blogId"); + userBlogs.put(normURL, blogID); + } + } + } + }); + } + + private FollowersModel mFollowersWPCOM; + private FollowersModel mFollowersEmail; + + @Override + protected boolean hasDataAvailable() { + return mFollowersWPCOM != null || mFollowersEmail != null; + } + @Override + protected void saveStatsData(Bundle outState) { + if (mFollowersWPCOM != null) { + outState.putSerializable(ARG_REST_RESPONSE, mFollowersWPCOM); + } + if (mFollowersEmail != null) { + outState.putSerializable(ARG_REST_RESPONSE_FOLLOWERS_EMAIL, mFollowersEmail); + } + } + @Override + protected void restoreStatsData(Bundle savedInstanceState) { + if (savedInstanceState.containsKey(ARG_REST_RESPONSE)) { + mFollowersWPCOM = (FollowersModel) savedInstanceState.getSerializable(ARG_REST_RESPONSE); + } + if (savedInstanceState.containsKey(ARG_REST_RESPONSE_FOLLOWERS_EMAIL)) { + mFollowersEmail = (FollowersModel) savedInstanceState.getSerializable(ARG_REST_RESPONSE_FOLLOWERS_EMAIL); + } + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.FollowersWPCOMUdated event) { + if (!shouldUpdateFragmentOnUpdateEvent(event)) { + return; + } + + mFollowersWPCOM = event.mFollowers; + updateUI(); + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.FollowersEmailUdated event) { + if (!shouldUpdateFragmentOnUpdateEvent(event)) { + return; + } + + mFollowersEmail = event.mFollowers; + updateUI(); + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.SectionUpdateError event) { + if (!shouldUpdateFragmentOnErrorEvent(event)) { + return; + } + + mFollowersWPCOM = null; + mFollowersEmail = null; + showErrorUI(event.mError); + } + + @Override + protected void updateUI() { + if (!isAdded()) { + return; + } + + if (!hasDataAvailable()) { + showHideNoResultsUI(true); + mTotalsLabel.setText(getTotalFollowersLabel(0)); + return; + } + + mTotalsLabel.setVisibility(View.VISIBLE); + + final FollowersModel followersModel = getCurrentDataModel(); + + if (followersModel != null && followersModel.getFollowers() != null && + followersModel.getFollowers().size() > 0) { + ArrayAdapter adapter = new DotComFollowerAdapter(getActivity(), followersModel.getFollowers()); + StatsUIHelper.reloadLinearLayout(getActivity(), adapter, mList, getMaxNumberOfItemsToShowInList()); + showHideNoResultsUI(false); + + if (mTopPagerSelectedButtonIndex == 0) { + mTotalsLabel.setText(getTotalFollowersLabel(followersModel.getTotalWPCom())); + } else { + mTotalsLabel.setText(getTotalFollowersLabel(followersModel.getTotalEmail())); + } + + if (isSingleView()) { + if (followersModel.getPages() > 1) { + mBottomPaginationContainer.setVisibility(View.VISIBLE); + mTopPaginationContainer.setVisibility(View.VISIBLE); + String paginationLabel = String.format( + getString(R.string.stats_pagination_label), + FormatUtils.formatDecimal(followersModel.getPage()), + FormatUtils.formatDecimal(followersModel.getPages()) + ); + mBottomPaginationText.setText(paginationLabel); + mTopPaginationText.setText(paginationLabel); + setNavigationButtonsEnabled(true); + + // Setting up back buttons + if (followersModel.getPage() == 1) { + // first page. No go back buttons + setNavigationBackButtonsVisibility(false); + } else { + setNavigationBackButtonsVisibility(true); + View.OnClickListener clickListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + setNavigationButtonsEnabled(false); + refreshStats( + followersModel.getPage() - 1, + new StatsService.StatsEndpointsEnum[]{sectionsToUpdate()[mTopPagerSelectedButtonIndex]} + ); + } + }; + mBottomPaginationGoBackButton.setOnClickListener(clickListener); + mTopPaginationGoBackButton.setOnClickListener(clickListener); + } + + // Setting up forward buttons + if (followersModel.getPage() == followersModel.getPages()) { + // last page. No go forward buttons + setNavigationForwardButtonsVisibility(false); + } else { + setNavigationForwardButtonsVisibility(true); + View.OnClickListener clickListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + setNavigationButtonsEnabled(false); + refreshStats( + followersModel.getPage() + 1, + new StatsService.StatsEndpointsEnum[]{sectionsToUpdate()[mTopPagerSelectedButtonIndex]} + ); + } + }; + mBottomPaginationGoForwardButton.setOnClickListener(clickListener); + mTopPaginationGoForwardButton.setOnClickListener(clickListener); + } + + // Change the total number of followers label by adding the current paging info + int startIndex = followersModel.getPage() * StatsService.MAX_RESULTS_REQUESTED_PER_PAGE - StatsService.MAX_RESULTS_REQUESTED_PER_PAGE + 1; + int endIndex = startIndex + followersModel.getFollowers().size() - 1; + String pagedLabel = getString( + mTopPagerSelectedButtonIndex == 0 ? R.string.stats_followers_total_wpcom_paged : R.string.stats_followers_total_email_paged, + startIndex, + endIndex, + FormatUtils.formatDecimal(mTopPagerSelectedButtonIndex == 0 ? followersModel.getTotalWPCom() : followersModel.getTotalEmail()) + ); + mTotalsLabel.setText(pagedLabel); + } else { + // No paging required. Hide the controls. + mBottomPaginationContainer.setVisibility(View.GONE); + mTopPaginationContainer.setVisibility(View.GONE); + } + } + } else { + showHideNoResultsUI(true); + mBottomPaginationContainer.setVisibility(View.GONE); + mTotalsLabel.setText(getTotalFollowersLabel(0)); + } + + // Always visible. Even if the current tab is empty, otherwise the user can't switch tab + mTopPagerContainer.setVisibility(View.VISIBLE); + } + + private FollowersModel getCurrentDataModel() { + return mTopPagerSelectedButtonIndex == 0 ? mFollowersWPCOM : mFollowersEmail; + } + + private void setNavigationBackButtonsVisibility(boolean visible) { + mBottomPaginationGoBackButton.setVisibility(visible ? View.VISIBLE : View.INVISIBLE); + mTopPaginationGoBackButton.setVisibility(visible ? View.VISIBLE : View.INVISIBLE); + } + + private void setNavigationForwardButtonsVisibility(boolean visible) { + mBottomPaginationGoForwardButton.setVisibility(visible ? View.VISIBLE : View.INVISIBLE); + mTopPaginationGoForwardButton.setVisibility(visible ? View.VISIBLE : View.INVISIBLE); + } + + private void setNavigationButtonsEnabled(boolean enable) { + mBottomPaginationGoBackButton.setEnabled(enable); + mBottomPaginationGoForwardButton.setEnabled(enable); + mTopPaginationGoBackButton.setEnabled(enable); + mTopPaginationGoForwardButton.setEnabled(enable); + } + + @Override + protected boolean isViewAllOptionAvailable() { + if (!hasDataAvailable()) { + return false; + } + FollowersModel followersModel = getCurrentDataModel(); + return !(followersModel == null || followersModel.getFollowers() == null + || followersModel.getFollowers().size() < MAX_NUM_OF_ITEMS_DISPLAYED_IN_LIST); + + } + + private String getTotalFollowersLabel(int total) { + final String totalFollowersLabel; + + if (mTopPagerSelectedButtonIndex == 0) { + totalFollowersLabel = getString(R.string.stats_followers_total_wpcom); + } else { + totalFollowersLabel = getString(R.string.stats_followers_total_email); + } + + return String.format(totalFollowersLabel, FormatUtils.formatDecimal(total)); + } + + @Override + protected boolean isExpandableList() { + return false; + } + + private class DotComFollowerAdapter extends ArrayAdapter<FollowerModel> { + + private final List<FollowerModel> list; + private final Activity context; + private final LayoutInflater inflater; + + public DotComFollowerAdapter(Activity context, List<FollowerModel> list) { + super(context, R.layout.stats_list_cell, list); + this.context = context; + this.list = list; + inflater = LayoutInflater.from(context); + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + View rowView = convertView; + // reuse views + if (rowView == null) { + rowView = inflater.inflate(R.layout.stats_list_cell, parent, false); + // set a min-width value that is large enough to contains the "since" string + LinearLayout totalContainer = (LinearLayout) rowView.findViewById(R.id.stats_list_cell_total_container); + int dp64 = DisplayUtils.dpToPx(rowView.getContext(), 64); + totalContainer.setMinimumWidth(dp64); + // configure view holder + StatsViewHolder viewHolder = new StatsViewHolder(rowView); + rowView.setTag(viewHolder); + } + + final FollowerModel currentRowData = list.get(position); + final StatsViewHolder holder = (StatsViewHolder) rowView.getTag(); + + holder.entryTextView.setTextColor(context.getResources().getColor(R.color.stats_text_color)); + holder.rowContent.setClickable(false); + + final FollowDataModel followData = currentRowData.getFollowData(); + + // entries + if (mTopPagerSelectedButtonIndex == 0 && !(TextUtils.isEmpty(currentRowData.getURL()) && followData == null)) { + // WPCOM followers with no empty URL or empty follow data + + final int blogID; + if (followData == null) { + // If follow data is empty, we cannot follow the blog, or access it in the reader. + // We need to check if the user is a member of this blog. + // If so, we can launch open the reader, otherwise open the blog in the in-app browser. + String normURL = normalizeAndRemoveScheme(currentRowData.getURL()); + blogID = userBlogs.containsKey(normURL) ? userBlogs.get(normURL) : Integer.MIN_VALUE; + } else { + blogID = followData.getSiteID(); + } + + if (blogID > Integer.MIN_VALUE) { + // Open the Reader + holder.entryTextView.setText(currentRowData.getLabel()); + holder.rowContent.setOnClickListener( + new View.OnClickListener() { + @Override + public void onClick(View view) { + ReaderActivityLauncher.showReaderBlogPreview( + context, + blogID + ); + } + }); + } else { + // Open the in-app web browser + holder.setEntryTextOrLink(currentRowData.getURL(), currentRowData.getLabel()); + } + holder.entryTextView.setTextColor(context.getResources().getColor(R.color.stats_link_text_color)); + } else { + // Email followers, or wpcom followers with empty URL and no blogID + holder.setEntryText(currentRowData.getLabel()); + } + + // since date + holder.totalsTextView.setText( + StatsUtils.getSinceLabel( + context, + currentRowData.getDateSubscribed() + ) + ); + + // Avatar + holder.networkImageView.setImageUrl( + GravatarUtils.fixGravatarUrl(currentRowData.getAvatar(), mResourceVars.headerAvatarSizePx), + WPNetworkImageView.ImageType.AVATAR); + holder.networkImageView.setVisibility(View.VISIBLE); + + if (followData == null) { + holder.imgMore.setVisibility(View.GONE); + holder.imgMore.setClickable(false); + } else { + holder.imgMore.setVisibility(View.VISIBLE); + holder.imgMore.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + FollowHelper fh = new FollowHelper(context); + fh.showPopup(holder.imgMore, followData); + } + }); + } + + return rowView; + } + + + } + + private static String normalizeAndRemoveScheme(String url) { + if (TextUtils.isEmpty(url)) { + return ""; + } + String normURL = UrlUtils.normalizeUrl(url.toLowerCase()); + int pos = normURL.indexOf("://"); + if (pos > -1) { + return normURL.substring(pos + 3); + } else { + return normURL; + } + } + + @Override + protected int getEntryLabelResId() { + return R.string.stats_entry_followers; + } + + @Override + protected int getTotalsLabelResId() { + return R.string.stats_totals_followers; + } + + @Override + protected int getEmptyLabelTitleResId() { + return R.string.stats_empty_followers; + } + + @Override + protected int getEmptyLabelDescResId() { + return R.string.stats_empty_followers_desc; + } + + @Override + protected StatsService.StatsEndpointsEnum[] sectionsToUpdate() { + return new StatsService.StatsEndpointsEnum[]{ + StatsService.StatsEndpointsEnum.FOLLOWERS_WPCOM, StatsService.StatsEndpointsEnum.FOLLOWERS_EMAIL + }; + } + + @Override + public String getTitle() { + return getString(R.string.stats_view_followers); + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsGeoviewsFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsGeoviewsFragment.java new file mode 100644 index 000000000..3622c817a --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsGeoviewsFragment.java @@ -0,0 +1,299 @@ +package org.wordpress.android.ui.stats; + +import android.app.Activity; +import android.net.http.SslError; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.webkit.SslErrorHandler; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import android.widget.ArrayAdapter; +import android.widget.LinearLayout; + +import org.wordpress.android.R; +import org.wordpress.android.ui.stats.models.GeoviewModel; +import org.wordpress.android.ui.stats.models.GeoviewsModel; +import org.wordpress.android.ui.stats.service.StatsService; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.DisplayUtils; +import org.wordpress.android.util.FormatUtils; +import org.wordpress.android.util.GravatarUtils; +import org.wordpress.android.widgets.WPNetworkImageView; + +import java.util.List; + + +public class StatsGeoviewsFragment extends StatsAbstractListFragment { + public static final String TAG = StatsGeoviewsFragment.class.getSimpleName(); + + private GeoviewsModel mCountries; + + @Override + protected boolean hasDataAvailable() { + return mCountries != null; + } + @Override + protected void saveStatsData(Bundle outState) { + if (hasDataAvailable()) { + outState.putSerializable(ARG_REST_RESPONSE, mCountries); + } + } + @Override + protected void restoreStatsData(Bundle savedInstanceState) { + if (savedInstanceState.containsKey(ARG_REST_RESPONSE)) { + mCountries = (GeoviewsModel) savedInstanceState.getSerializable(ARG_REST_RESPONSE); + } + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.CountriesUpdated event) { + if (!shouldUpdateFragmentOnUpdateEvent(event)) { + return; + } + + mCountries = event.mCountries; + updateUI(); + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.SectionUpdateError event) { + if (!shouldUpdateFragmentOnErrorEvent(event)) { + return; + } + + mCountries = null; + showErrorUI(event.mError); + } + + private void hideMap() { + if (!isAdded()) { + return; + } + + mTopPagerContainer.setVisibility(View.GONE); + } + + private void showMap(final List<GeoviewModel> countries) { + if (!isAdded()) { + return; + } + + // setting up different margins for the map. We're basically remove left margins since the + // chart service produce a map that's slightly shifted on the right. See the Web version. + int dp4 = DisplayUtils.dpToPx(mTopPagerContainer.getContext(), 4); + LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT); + layoutParams.setMargins(0, 0, dp4, 0); + mTopPagerContainer.setLayoutParams(layoutParams); + + mTopPagerContainer.removeAllViews(); + + // must wait for mTopPagerContainer to be fully laid out (ie: measured). Then we can read the width and + // calculate the right height for the map div + mTopPagerContainer.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + mTopPagerContainer.getViewTreeObserver().removeGlobalOnLayoutListener(this); + if (!isAdded()) { + return; + } + + StringBuilder dataToLoad = new StringBuilder(); + + for (int i = 0; i < countries.size(); i++) { + final GeoviewModel currentCountry = countries.get(i); + dataToLoad.append("['").append(currentCountry.getCountryFullName()).append("',") + .append(currentCountry.getViews()).append("],"); + } + + // This is the label that is shown when the user taps on a region + String label = getResources().getString(getTotalsLabelResId()); + + // See: https://developers.google.com/chart/interactive/docs/gallery/geochart + // Loading the v42 of the Google Charts API, since the latest stable version has a problem with the legend. https://github.com/wordpress-mobile/WordPress-Android/issues/4131 + // https://developers.google.com/chart/interactive/docs/release_notes#release-candidate-details + String htmlPage = "<html>" + + "<head>" + + "<script type=\"text/javascript\" src=\"https://www.gstatic.com/charts/loader.js\"></script>" + + "<script type=\"text/javascript\" src=\"https://www.google.com/jsapi\"></script>" + + "<script type=\"text/javascript\">" + + "google.charts.load('42', {'packages':['geochart']});" + + "google.charts.setOnLoadCallback(drawRegionsMap);" + + "function drawRegionsMap() {" + + "var data = google.visualization.arrayToDataTable(" + + "[" + + "['Country', '" + label + "']," + + dataToLoad + + "]);" + + "var options = {keepAspectRatio: true, region: 'world', colorAxis: { colors: [ '#FFF088', '#F34605' ] }, enableRegionInteractivity: true};" + + "var chart = new google.visualization.GeoChart(document.getElementById('regions_div'));" + + "chart.draw(data, options);" + + "}" + + "</script>" + + "</head>" + + "<body>" + + "<div id=\"regions_div\" style=\"width: 100%; height: 100%;\"></div>" + + "</body>" + + "</html>"; + + WebView webView = new WebView(getActivity()); + mTopPagerContainer.addView(webView); + + int width = mTopPagerContainer.getWidth(); + int height = width * 3 / 4; + + LinearLayout.LayoutParams params = (LinearLayout.LayoutParams) webView.getLayoutParams(); + params.width = WebView.LayoutParams.MATCH_PARENT; + params.height = height; + + webView.setLayoutParams(params); + + webView.setWebViewClient(new MyWebViewClient()); // Hide map in case of unrecoverable errors + webView.getSettings().setJavaScriptEnabled(true); + webView.getSettings().setCacheMode(WebSettings.LOAD_NO_CACHE); + webView.loadData(htmlPage, "text/html", "UTF-8"); + + } + }); + mTopPagerContainer.setVisibility(View.VISIBLE); + } + + // Hide the Map in case of errors + private class MyWebViewClient extends WebViewClient { + @Override + public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) { + super.onReceivedError(view, errorCode, description, failingUrl); + mTopPagerContainer.setVisibility(View.GONE); + AppLog.e(AppLog.T.STATS, "Cannot load geochart." + + " ErrorCode: " + errorCode + + " Description: " + description + + " Failing URL: " + failingUrl); + } + @Override + public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) { + super.onReceivedSslError(view, handler, error); + mTopPagerContainer.setVisibility(View.GONE); + AppLog.e(AppLog.T.STATS, "Cannot load geochart. SSL ERROR. " + error.toString()); + } + } + + @Override + protected void updateUI() { + if (!isAdded()) { + return; + } + + if (hasCountries()) { + List<GeoviewModel> countries = getCountries(); + ArrayAdapter adapter = new GeoviewsAdapter(getActivity(), countries); + StatsUIHelper.reloadLinearLayout(getActivity(), adapter, mList, getMaxNumberOfItemsToShowInList()); + showHideNoResultsUI(false); + showMap(countries); + } else { + showHideNoResultsUI(true); + hideMap(); + } + } + + private boolean hasCountries() { + return mCountries != null && mCountries.getCountries() != null; + } + + private List<GeoviewModel> getCountries() { + if (!hasCountries()) { + return null; + } + return mCountries.getCountries(); + } + + @Override + protected boolean isViewAllOptionAvailable() { + return (hasCountries() + && mCountries.getCountries().size() > MAX_NUM_OF_ITEMS_DISPLAYED_IN_LIST); + } + + @Override + protected boolean isExpandableList() { + return false; + } + + private class GeoviewsAdapter extends ArrayAdapter<GeoviewModel> { + + private final List<GeoviewModel> list; + private final Activity context; + private final LayoutInflater inflater; + + public GeoviewsAdapter(Activity context, List<GeoviewModel> list) { + super(context, R.layout.stats_list_cell, list); + this.context = context; + this.list = list; + inflater = LayoutInflater.from(context); + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + View rowView = convertView; + // reuse views + if (rowView == null) { + rowView = inflater.inflate(R.layout.stats_list_cell, parent, false); + // configure view holder + StatsViewHolder viewHolder = new StatsViewHolder(rowView); + rowView.setTag(viewHolder); + } + + final GeoviewModel currentRowData = list.get(position); + StatsViewHolder holder = (StatsViewHolder) rowView.getTag(); + // fill data + String entry = currentRowData.getCountryFullName(); + String imageUrl = currentRowData.getFlatFlagIconURL(); + int total = currentRowData.getViews(); + + holder.setEntryText(entry); + holder.totalsTextView.setText(FormatUtils.formatDecimal(total)); + + // image (country flag) + holder.networkImageView.setImageUrl( + GravatarUtils.fixGravatarUrl(imageUrl, mResourceVars.headerAvatarSizePx), + WPNetworkImageView.ImageType.BLAVATAR); + holder.networkImageView.setVisibility(View.VISIBLE); + + return rowView; + } + } + + @Override + protected int getEntryLabelResId() { + return R.string.stats_entry_country; + } + + @Override + protected int getTotalsLabelResId() { + return R.string.stats_totals_views; + } + + @Override + protected int getEmptyLabelTitleResId() { + return R.string.stats_empty_geoviews; + } + + @Override + protected int getEmptyLabelDescResId() { + return R.string.stats_empty_geoviews_desc; + } + + @Override + protected StatsService.StatsEndpointsEnum[] sectionsToUpdate() { + return new StatsService.StatsEndpointsEnum[]{ + StatsService.StatsEndpointsEnum.GEO_VIEWS + }; + } + + @Override + public String getTitle() { + return getString(R.string.stats_view_countries); + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsInsightsAllTimeFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsInsightsAllTimeFragment.java new file mode 100644 index 000000000..8fa6a17f5 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsInsightsAllTimeFragment.java @@ -0,0 +1,99 @@ +package org.wordpress.android.ui.stats; + +import android.os.Bundle; +import android.view.ViewGroup; +import android.widget.LinearLayout; +import android.widget.TextView; + +import org.wordpress.android.R; +import org.wordpress.android.ui.stats.models.InsightsAllTimeModel; +import org.wordpress.android.ui.stats.service.StatsService; +import org.wordpress.android.util.FormatUtils; + + +public class StatsInsightsAllTimeFragment extends StatsAbstractInsightsFragment { + public static final String TAG = StatsInsightsAllTimeFragment.class.getSimpleName(); + + private InsightsAllTimeModel mInsightsAllTimeModel; + + @Override + protected boolean hasDataAvailable() { + return mInsightsAllTimeModel != null; + } + @Override + protected void saveStatsData(Bundle outState) { + if (hasDataAvailable()) { + outState.putSerializable(ARG_REST_RESPONSE, mInsightsAllTimeModel); + } + } + @Override + protected void restoreStatsData(Bundle savedInstanceState) { + if (savedInstanceState.containsKey(ARG_REST_RESPONSE)) { + mInsightsAllTimeModel = (InsightsAllTimeModel) savedInstanceState.getSerializable(ARG_REST_RESPONSE); + } + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.InsightsAllTimeUpdated event) { + if (!shouldUpdateFragmentOnUpdateEvent(event)) { + return; + } + + mInsightsAllTimeModel = event.mInsightsAllTimeModel; + updateUI(); + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.SectionUpdateError event) { + if (!shouldUpdateFragmentOnErrorEvent(event)) { + return; + } + + mInsightsAllTimeModel = null; + showErrorUI(event.mError); + } + + + protected void updateUI() { + super.updateUI(); + + if (!isAdded() || !hasDataAvailable()) { + return; + } + + LinearLayout ll = (LinearLayout) getActivity().getLayoutInflater() + .inflate(R.layout.stats_insights_all_time_item, (ViewGroup) mResultContainer.getRootView(), false); + + TextView postsTextView = (TextView) ll.findViewById(R.id.stats_all_time_posts); + TextView viewsTextView = (TextView) ll.findViewById(R.id.stats_all_time_views); + TextView visitorsTextView = (TextView) ll.findViewById(R.id.stats_all_time_visitors); + TextView besteverTextView = (TextView) ll.findViewById(R.id.stats_all_time_bestever); + TextView besteverDateTextView = (TextView) ll.findViewById(R.id.stats_all_time_bestever_date); + + + postsTextView.setText(FormatUtils.formatDecimal(mInsightsAllTimeModel.getPosts())); + viewsTextView.setText(FormatUtils.formatDecimal(mInsightsAllTimeModel.getViews())); + visitorsTextView.setText(FormatUtils.formatDecimal(mInsightsAllTimeModel.getVisitors())); + + besteverTextView.setText(FormatUtils.formatDecimal(mInsightsAllTimeModel.getViewsBestDayTotal())); + besteverDateTextView.setText( + StatsUtils.parseDate(mInsightsAllTimeModel.getViewsBestDay(), StatsConstants.STATS_INPUT_DATE_FORMAT, "MMMM dd, yyyy") + ); + + mResultContainer.addView(ll); + } + + + @Override + protected StatsService.StatsEndpointsEnum[] sectionsToUpdate() { + return new StatsService.StatsEndpointsEnum[]{ + StatsService.StatsEndpointsEnum.INSIGHTS_ALL_TIME + }; + } + + @Override + public String getTitle() { + return getString(R.string.stats_insights_all_time); + } + +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsInsightsLatestPostSummaryFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsInsightsLatestPostSummaryFragment.java new file mode 100644 index 000000000..c65b2053c --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsInsightsLatestPostSummaryFragment.java @@ -0,0 +1,280 @@ +package org.wordpress.android.ui.stats; + +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.TextUtils; +import android.text.style.ForegroundColorSpan; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import org.apache.commons.lang.StringEscapeUtils; +import org.wordpress.android.R; +import org.wordpress.android.ui.ActivityLauncher; +import org.wordpress.android.ui.stats.models.InsightsLatestPostDetailsModel; +import org.wordpress.android.ui.stats.models.InsightsLatestPostModel; +import org.wordpress.android.ui.stats.models.PostModel; +import org.wordpress.android.ui.stats.service.StatsService; +import org.wordpress.android.util.FormatUtils; + +public class StatsInsightsLatestPostSummaryFragment extends StatsAbstractInsightsFragment { + public static final String TAG = StatsInsightsLatestPostSummaryFragment.class.getSimpleName(); + + private static final String ARG_REST_RESPONSE_DETAILS = "ARG_REST_RESPONSE_DETAILS"; + + private InsightsLatestPostModel mInsightsLatestPostModel; + private InsightsLatestPostDetailsModel mInsightsLatestPostDetailsModel; + + @Override + protected boolean hasDataAvailable() { + return mInsightsLatestPostModel != null && mInsightsLatestPostDetailsModel != null; + } + @Override + protected void saveStatsData(Bundle outState) { + if (hasDataAvailable()) { + outState.putSerializable(ARG_REST_RESPONSE, mInsightsLatestPostModel); + outState.putSerializable(ARG_REST_RESPONSE_DETAILS, mInsightsLatestPostDetailsModel); + } + } + @Override + protected void restoreStatsData(Bundle savedInstanceState) { + if (savedInstanceState.containsKey(ARG_REST_RESPONSE)) { + mInsightsLatestPostModel = (InsightsLatestPostModel) savedInstanceState.getSerializable(ARG_REST_RESPONSE); + } + if (savedInstanceState.containsKey(ARG_REST_RESPONSE_DETAILS)) { + mInsightsLatestPostDetailsModel = (InsightsLatestPostDetailsModel) savedInstanceState.getSerializable(ARG_REST_RESPONSE_DETAILS); + } + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.InsightsLatestPostSummaryUpdated event) { + if (!shouldUpdateFragmentOnUpdateEvent(event)) { + return; + } + + if (event.mInsightsLatestPostModel == null) { + showErrorUI(); + return; + } + + mInsightsLatestPostModel = event.mInsightsLatestPostModel; + + // check if there is a post "published" on the blog + View mainView = getView(); + if (mainView != null) { + mainView.setVisibility(mInsightsLatestPostModel.isLatestPostAvailable() ? View.VISIBLE : View.GONE); + } + if (!mInsightsLatestPostModel.isLatestPostAvailable()) { + // No need to go further into UI updating. There are no posts on this blog and the + // entire fragment is hidden. + return; + } + + // Check if we already have the number of "views" for the latest post + if (mInsightsLatestPostModel.getPostViewsCount() == Integer.MIN_VALUE) { + // we don't have the views count. Need to call the service again here + refreshStats(mInsightsLatestPostModel.getPostID(), + new StatsService.StatsEndpointsEnum[]{StatsService.StatsEndpointsEnum.INSIGHTS_LATEST_POST_VIEWS}); + showPlaceholderUI(); + } else { + updateUI(); + } + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.InsightsLatestPostDetailsUpdated event) { + if (!shouldUpdateFragmentOnUpdateEvent(event)) { + return; + } + + if (mInsightsLatestPostModel == null || event.mInsightsLatestPostDetailsModel == null) { + showErrorUI(); + return; + } + + mInsightsLatestPostDetailsModel = event.mInsightsLatestPostDetailsModel; + mInsightsLatestPostModel.setPostViewsCount(mInsightsLatestPostDetailsModel.getPostViewsCount()); + updateUI(); + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.SectionUpdateError event) { + + if (!shouldUpdateFragmentOnErrorEvent(event) + && event.mEndPointName != StatsService.StatsEndpointsEnum.INSIGHTS_LATEST_POST_VIEWS ) { + return; + } + + mInsightsLatestPostDetailsModel = null; + mInsightsLatestPostModel = null; + showErrorUI(event.mError); + } + + protected void updateUI() { + super.updateUI(); + + if (!isAdded() || !hasDataAvailable()) { + return; + } + + // check if there are posts "published" on the blog + if (!mInsightsLatestPostModel.isLatestPostAvailable()) { + // No need to go further into UI updating. There are no posts on this blog and the + // entire fragment is hidden. + return; + } + + TextView moduleTitle = (TextView) getView().findViewById(R.id.stats_module_title); + moduleTitle.setOnClickListener(ViewsTabOnClickListener); + moduleTitle.setTextColor(getResources().getColor(R.color.stats_link_text_color)); + + // update the tabs and the text now + LinearLayout ll = (LinearLayout) getActivity().getLayoutInflater() + .inflate(R.layout.stats_insights_latest_post_item, (ViewGroup) mResultContainer.getRootView(), false); + + String trendLabel = getString(R.string.stats_insights_latest_post_trend); + String sinceLabel = StatsUtils.getSinceLabel( + getActivity(), + mInsightsLatestPostModel.getPostDate() + ).toLowerCase(); + + String postTitle = StringEscapeUtils.unescapeHtml(mInsightsLatestPostModel.getPostTitle()); + if (TextUtils.isEmpty(postTitle)) { + postTitle = getString(R.string.stats_insights_latest_post_no_title); + } + + final String trendLabelFormatted = String.format( + trendLabel, sinceLabel, postTitle); + + int startIndex, endIndex; + startIndex = trendLabelFormatted.indexOf(postTitle); + endIndex = startIndex + postTitle.length() +1; + + Spannable descriptionTextToSpan = new SpannableString(trendLabelFormatted); + descriptionTextToSpan.setSpan(new ForegroundColorSpan(getResources().getColor(R.color.stats_link_text_color)), + startIndex, endIndex, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + TextView trendLabelTextField = (TextView) ll.findViewById(R.id.stats_post_trend_label); + trendLabelTextField.setText(descriptionTextToSpan); + trendLabelTextField.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + StatsUtils.openPostInReaderOrInAppWebview(getActivity(), + mInsightsLatestPostModel.getBlogID(), + String.valueOf(mInsightsLatestPostModel.getPostID()), + StatsConstants.ITEM_TYPE_POST, + mInsightsLatestPostModel.getPostURL()); + } + }); + + LinearLayout tabs = (LinearLayout) ll.findViewById(R.id.stats_latest_post_tabs); + + for (int i = 0; i < tabs.getChildCount(); i++) { + LinearLayout currentTab = (LinearLayout) tabs.getChildAt(i); + switch (i) { + case 0: + setupTab(currentTab, FormatUtils.formatDecimal(mInsightsLatestPostModel.getPostViewsCount()), StatsVisitorsAndViewsFragment.OverviewLabel.VIEWS); + break; + case 1: + setupTab(currentTab, FormatUtils.formatDecimal(mInsightsLatestPostModel.getPostLikeCount()), StatsVisitorsAndViewsFragment.OverviewLabel.LIKES); + break; + case 2: + setupTab(currentTab, FormatUtils.formatDecimal(mInsightsLatestPostModel.getPostCommentCount()), StatsVisitorsAndViewsFragment.OverviewLabel.COMMENTS); + break; + } + } + + mResultContainer.addView(ll); + } + + private void setupTab(LinearLayout currentTab, String total, final StatsVisitorsAndViewsFragment.OverviewLabel itemType) { + final TextView label; + final TextView value; + final ImageView icon; + + currentTab.setTag(itemType); + // Only Views is clickable here + if (itemType == StatsVisitorsAndViewsFragment.OverviewLabel.VIEWS) { + currentTab.setOnClickListener(ViewsTabOnClickListener); + } else { + currentTab.setClickable(false); + } + + label = (TextView) currentTab.findViewById(R.id.stats_visitors_and_views_tab_label); + label.setText(itemType.getLabel()); + value = (TextView) currentTab.findViewById(R.id.stats_visitors_and_views_tab_value); + value.setText(total); + if (total.equals("0")) { + value.setTextColor(getResources().getColor(R.color.grey)); + } else { + // Only Views is clickable here. + // Likes and Comments shouldn't link anywhere because they don't have summaries + // so their color should be Gray Darken 30 or #3d596d + if (itemType == StatsVisitorsAndViewsFragment.OverviewLabel.VIEWS) { + value.setTextColor(getResources().getColor(R.color.blue_wordpress)); + } else { + value.setTextColor(getResources().getColor(R.color.grey_darken_30)); + } + } + icon = (ImageView) currentTab.findViewById(R.id.stats_visitors_and_views_tab_icon); + icon.setImageDrawable(getTabIcon(itemType)); + + if (itemType == StatsVisitorsAndViewsFragment.OverviewLabel.COMMENTS) { + currentTab.setBackgroundResource(R.drawable.stats_visitors_and_views_button_latest_white); + } else { + currentTab.setBackgroundResource(R.drawable.stats_visitors_and_views_button_white); + } + } + + private final View.OnClickListener ViewsTabOnClickListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + if (!isAdded()) { + return; + } + + // Another check that the data is available + if (mInsightsLatestPostModel == null) { + showErrorUI(); + return; + } + + PostModel postModel = new PostModel( + mInsightsLatestPostModel.getBlogID(), + String.valueOf(mInsightsLatestPostModel.getPostID()), + mInsightsLatestPostModel.getPostTitle(), + mInsightsLatestPostModel.getPostURL(), + StatsConstants.ITEM_TYPE_POST); + ActivityLauncher.viewStatsSinglePostDetails(getActivity(), postModel); + } + }; + + private Drawable getTabIcon(final StatsVisitorsAndViewsFragment.OverviewLabel labelItem) { + switch (labelItem) { + case VISITORS: + return getResources().getDrawable(R.drawable.stats_icon_visitors); + case COMMENTS: + return getResources().getDrawable(R.drawable.stats_icon_comments); + case LIKES: + return getResources().getDrawable(R.drawable.stats_icon_likes); + default: + // Views and when no prev match + return getResources().getDrawable(R.drawable.stats_icon_views); + } + } + + @Override + protected StatsService.StatsEndpointsEnum[] sectionsToUpdate() { + return new StatsService.StatsEndpointsEnum[]{ + StatsService.StatsEndpointsEnum.INSIGHTS_LATEST_POST_SUMMARY, + }; + } + + @Override + public String getTitle() { + return getString(R.string.stats_insights_latest_post_summary); + } +}
\ No newline at end of file diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsInsightsMostPopularFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsInsightsMostPopularFragment.java new file mode 100644 index 000000000..247ca80b2 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsInsightsMostPopularFragment.java @@ -0,0 +1,149 @@ +package org.wordpress.android.ui.stats; + +import android.os.Bundle; +import android.view.ViewGroup; +import android.widget.LinearLayout; +import android.widget.TextView; + +import org.wordpress.android.R; +import org.wordpress.android.ui.stats.models.InsightsPopularModel; +import org.wordpress.android.ui.stats.service.StatsService; + +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.Calendar; + + +public class StatsInsightsMostPopularFragment extends StatsAbstractInsightsFragment { + public static final String TAG = StatsInsightsMostPopularFragment.class.getSimpleName(); + + private InsightsPopularModel mInsightsPopularModel; + + @Override + protected boolean hasDataAvailable() { + return mInsightsPopularModel != null; + } + @Override + protected void saveStatsData(Bundle outState) { + if (hasDataAvailable()) { + outState.putSerializable(ARG_REST_RESPONSE, mInsightsPopularModel); + } + } + @Override + protected void restoreStatsData(Bundle savedInstanceState) { + if (savedInstanceState.containsKey(ARG_REST_RESPONSE)) { + mInsightsPopularModel = (InsightsPopularModel) savedInstanceState.getSerializable(ARG_REST_RESPONSE); + } + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.InsightsPopularUpdated event) { + if (!shouldUpdateFragmentOnUpdateEvent(event)) { + return; + } + + mInsightsPopularModel = event.mInsightsPopularModel; + updateUI(); + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.SectionUpdateError event) { + if (!shouldUpdateFragmentOnErrorEvent(event)) { + return; + } + + mInsightsPopularModel = null; + showErrorUI(event.mError); + } + + protected void updateUI() { + super.updateUI(); + + if (!isAdded() || !hasDataAvailable()) { + return; + } + + LinearLayout ll = (LinearLayout) getActivity().getLayoutInflater() + .inflate(R.layout.stats_insights_most_popular_item, (ViewGroup) mResultContainer.getRootView(), false); + + int dayOfTheWeek = mInsightsPopularModel.getHighestDayOfWeek(); + + Calendar c = Calendar.getInstance(); + c.setFirstDayOfWeek(Calendar.MONDAY); + c.setTimeInMillis(System.currentTimeMillis()); + switch (dayOfTheWeek) { + case 0: + c.set(Calendar.DAY_OF_WEEK, Calendar.MONDAY); + break; + case 1: + c.set(Calendar.DAY_OF_WEEK, Calendar.TUESDAY); + break; + case 2: + c.set(Calendar.DAY_OF_WEEK, Calendar.WEDNESDAY); + break; + case 3: + c.set(Calendar.DAY_OF_WEEK, Calendar.THURSDAY); + break; + case 4: + c.set(Calendar.DAY_OF_WEEK, Calendar.FRIDAY); + break; + case 5: + c.set(Calendar.DAY_OF_WEEK, Calendar.SATURDAY); + break; + case 6: + c.set(Calendar.DAY_OF_WEEK, Calendar.SUNDAY); + break; + } + + DateFormat formatter = new SimpleDateFormat("EEEE"); + final TextView mostPopularDayTextView = (TextView) ll.findViewById(R.id.stats_most_popular_day); + mostPopularDayTextView.setText(formatter.format(c.getTime())); + final TextView mostPopularDayPercentTextView = (TextView) ll.findViewById(R.id.stats_most_popular_day_percent); + mostPopularDayPercentTextView.setText( + String.format( + getString(R.string.stats_insights_most_popular_percent_views), + roundToInteger(mInsightsPopularModel.getHighestDayPercent()) + ) + ); + + TextView mostPopularHourTextView = (TextView) ll.findViewById(R.id.stats_most_popular_hour); + DateFormat timeFormat = android.text.format.DateFormat.getTimeFormat(getActivity()); + c.set(Calendar.HOUR_OF_DAY, mInsightsPopularModel.getHighestHour()); + c.set(Calendar.MINUTE, 0); + mostPopularHourTextView.setText(timeFormat.format(c.getTime())); + final TextView mostPopularHourPercentTextView = (TextView) ll.findViewById(R.id.stats_most_popular_hour_percent); + mostPopularHourPercentTextView.setText( + String.format( + getString(R.string.stats_insights_most_popular_percent_views), + roundToInteger(mInsightsPopularModel.getHighestHourPercent()) + ) + ); + + mResultContainer.addView(ll); + } + + /* + * Round a double to the closest integer + * + * If the decimal part is less than 0.5, the integer part stays the same, + * and truncation gives the right result. + * If the decimal part is more that 0.5, the integer part increments, + * and again truncation gives what we want. + * + */ + private int roundToInteger(double inputValue) { + return (int) Math.floor(inputValue + 0.5); + } + + @Override + protected StatsService.StatsEndpointsEnum[] sectionsToUpdate() { + return new StatsService.StatsEndpointsEnum[]{ + StatsService.StatsEndpointsEnum.INSIGHTS_POPULAR + }; + } + + @Override + public String getTitle() { + return getString(R.string.stats_insights_popular); + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsInsightsTodayFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsInsightsTodayFragment.java new file mode 100644 index 000000000..0f1ae466b --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsInsightsTodayFragment.java @@ -0,0 +1,200 @@ +package org.wordpress.android.ui.stats; + +import android.app.Activity; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import org.wordpress.android.R; +import org.wordpress.android.ui.stats.models.VisitModel; +import org.wordpress.android.ui.stats.models.VisitsModel; +import org.wordpress.android.ui.stats.service.StatsService; +import org.wordpress.android.util.FormatUtils; + +import java.util.List; + + +public class StatsInsightsTodayFragment extends StatsAbstractInsightsFragment { + public static final String TAG = StatsInsightsTodayFragment.class.getSimpleName(); + + // Container Activity must implement this interface + public interface OnInsightsTodayClickListener { + void onInsightsTodayClicked(StatsVisitorsAndViewsFragment.OverviewLabel item); + } + + private OnInsightsTodayClickListener mListener; + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + try { + mListener = (OnInsightsTodayClickListener) activity; + } catch (ClassCastException e) { + throw new ClassCastException(activity.toString() + " must implement OnInsightsTodayClickListener"); + } + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View view = super.onCreateView(inflater, container, savedInstanceState); + TextView moduleTitle = (TextView) view.findViewById(R.id.stats_module_title); + moduleTitle.setTag(StatsVisitorsAndViewsFragment.OverviewLabel.VIEWS); + moduleTitle.setOnClickListener(ButtonsOnClickListener); + moduleTitle.setTextColor(getResources().getColor(R.color.stats_link_text_color)); + return view; + } + + + private VisitsModel mVisitsModel; + + @Override + protected boolean hasDataAvailable() { + return mVisitsModel != null; + } + @Override + protected void saveStatsData(Bundle outState) { + if (hasDataAvailable()) { + outState.putSerializable(ARG_REST_RESPONSE, mVisitsModel); + } + } + @Override + protected void restoreStatsData(Bundle savedInstanceState) { + if (savedInstanceState.containsKey(ARG_REST_RESPONSE)) { + mVisitsModel = (VisitsModel) savedInstanceState.getSerializable(ARG_REST_RESPONSE); + } + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.VisitorsAndViewsUpdated event) { + if (!shouldUpdateFragmentOnUpdateEvent(event)) { + return; + } + + mVisitsModel = event.mVisitsAndViews; + updateUI(); + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.SectionUpdateError event) { + if (!shouldUpdateFragmentOnErrorEvent(event)) { + return; + } + + mVisitsModel = null; + showErrorUI(event.mError); + } + + protected void updateUI() { + super.updateUI(); + + if (!isAdded() || !hasDataAvailable()) { + return; + } + + if (mVisitsModel.getVisits() == null || mVisitsModel.getVisits().size() == 0) { + showErrorUI(); + return; + } + + List<VisitModel> visits = mVisitsModel.getVisits(); + VisitModel data = visits.get(visits.size() - 1); + + LinearLayout ll = (LinearLayout) getActivity().getLayoutInflater() + .inflate(R.layout.stats_insights_today_item, (ViewGroup) mResultContainer.getRootView(), false); + + LinearLayout tabs = (LinearLayout) ll.findViewById(R.id.stats_post_tabs); + + for (int i = 0; i < tabs.getChildCount(); i++) { + LinearLayout currentTab = (LinearLayout) tabs.getChildAt(i); + switch (i) { + case 0: + setupTab(currentTab, FormatUtils.formatDecimal(data.getViews()), StatsVisitorsAndViewsFragment.OverviewLabel.VIEWS); + break; + case 1: + setupTab(currentTab, FormatUtils.formatDecimal(data.getVisitors()), StatsVisitorsAndViewsFragment.OverviewLabel.VISITORS ); + break; + case 2: + setupTab(currentTab, FormatUtils.formatDecimal(data.getLikes()), StatsVisitorsAndViewsFragment.OverviewLabel.LIKES ); + break; + case 3: + setupTab(currentTab, FormatUtils.formatDecimal(data.getComments()), StatsVisitorsAndViewsFragment.OverviewLabel.COMMENTS ); + break; + } + } + + mResultContainer.addView(ll); + } + + private void setupTab(LinearLayout currentTab, String total, final StatsVisitorsAndViewsFragment.OverviewLabel itemType) { + final TextView label; + final TextView value; + final ImageView icon; + + currentTab.setTag(itemType); + currentTab.setOnClickListener(ButtonsOnClickListener); + + label = (TextView) currentTab.findViewById(R.id.stats_visitors_and_views_tab_label); + label.setText(itemType.getLabel()); + value = (TextView) currentTab.findViewById(R.id.stats_visitors_and_views_tab_value); + value.setText(total); + if (total.equals("0")) { + value.setTextColor(getResources().getColor(R.color.grey)); + } else { + value.setTextColor(getResources().getColor(R.color.blue_wordpress)); + } + icon = (ImageView) currentTab.findViewById(R.id.stats_visitors_and_views_tab_icon); + icon.setImageDrawable(getTabIcon(itemType)); + + if (itemType == StatsVisitorsAndViewsFragment.OverviewLabel.COMMENTS) { + currentTab.setBackgroundResource(R.drawable.stats_visitors_and_views_button_latest_white); + } else { + currentTab.setBackgroundResource(R.drawable.stats_visitors_and_views_button_white); + } + } + + private final View.OnClickListener ButtonsOnClickListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + if (!isAdded()) { + return; + } + if (mListener == null) { + return; + } + StatsVisitorsAndViewsFragment.OverviewLabel tag = (StatsVisitorsAndViewsFragment.OverviewLabel) v.getTag(); + mListener.onInsightsTodayClicked(tag); + } + }; + + private Drawable getTabIcon(final StatsVisitorsAndViewsFragment.OverviewLabel labelItem) { + switch (labelItem) { + case VISITORS: + return getResources().getDrawable(R.drawable.stats_icon_visitors); + case COMMENTS: + return getResources().getDrawable(R.drawable.stats_icon_comments); + case LIKES: + return getResources().getDrawable(R.drawable.stats_icon_likes); + default: + // Views and when no prev match + return getResources().getDrawable(R.drawable.stats_icon_views); + } + } + + @Override + protected StatsService.StatsEndpointsEnum[] sectionsToUpdate() { + return new StatsService.StatsEndpointsEnum[]{ + StatsService.StatsEndpointsEnum.VISITS + }; + } + + @Override + public String getTitle() { + return getString(R.string.stats_insights_today); + } + +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsPublicizeFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsPublicizeFragment.java new file mode 100644 index 000000000..84522cf08 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsPublicizeFragment.java @@ -0,0 +1,238 @@ +package org.wordpress.android.ui.stats; + +import android.app.Activity; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; + +import org.wordpress.android.R; +import org.wordpress.android.ui.stats.models.PublicizeModel; +import org.wordpress.android.ui.stats.models.SingleItemModel; +import org.wordpress.android.ui.stats.service.StatsService; +import org.wordpress.android.util.FormatUtils; +import org.wordpress.android.util.GravatarUtils; +import org.wordpress.android.util.StringUtils; +import org.wordpress.android.widgets.WPNetworkImageView; + +import java.util.List; + + +public class StatsPublicizeFragment extends StatsAbstractListFragment { + public static final String TAG = StatsPublicizeFragment.class.getSimpleName(); + + private PublicizeModel mPublicizeData; + + @Override + protected boolean hasDataAvailable() { + return mPublicizeData != null; + } + @Override + protected void saveStatsData(Bundle outState) { + if (mPublicizeData != null) { + outState.putSerializable(ARG_REST_RESPONSE, mPublicizeData); + } + } + @Override + protected void restoreStatsData(Bundle savedInstanceState) { + if (savedInstanceState.containsKey(ARG_REST_RESPONSE)) { + mPublicizeData = (PublicizeModel) savedInstanceState.getSerializable(ARG_REST_RESPONSE); + } + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.PublicizeUpdated event) { + if (!shouldUpdateFragmentOnUpdateEvent(event)) { + return; + } + + mPublicizeData = event.mPublicizeModel; + updateUI(); + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.SectionUpdateError event) { + if (!shouldUpdateFragmentOnErrorEvent(event)) { + return; + } + + mPublicizeData = null; + showErrorUI(event.mError); + } + + @Override + protected void updateUI() { + if (!isAdded()) { + return; + } + + if (hasPublicize()) { + ArrayAdapter adapter = new PublicizeAdapter(getActivity(), getPublicize()); + StatsUIHelper.reloadLinearLayout(getActivity(), adapter, mList, getMaxNumberOfItemsToShowInList()); + showHideNoResultsUI(false); + } else { + showHideNoResultsUI(true); + } + } + + private boolean hasPublicize() { + return mPublicizeData != null + && mPublicizeData.getServices() != null + && mPublicizeData.getServices().size() > 0; + } + + private List<SingleItemModel> getPublicize() { + if (!hasPublicize()) { + return null; + } + return mPublicizeData.getServices(); + } + + @Override + protected boolean isViewAllOptionAvailable() { + return false; + } + + @Override + protected boolean isExpandableList() { + return false; + } + + private class PublicizeAdapter extends ArrayAdapter<SingleItemModel> { + + private final List<SingleItemModel> list; + private final LayoutInflater inflater; + + public PublicizeAdapter(Activity context, List<SingleItemModel> list) { + super(context, R.layout.stats_list_cell, list); + this.list = list; + inflater = LayoutInflater.from(context); + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + View rowView = convertView; + // reuse views + final StatsViewHolder holder; + if (rowView == null) { + rowView = inflater.inflate(R.layout.stats_list_cell, parent, false); + // configure view holder + holder = new StatsViewHolder(rowView); + holder.networkImageView.setErrorImageResId(R.drawable.stats_icon_default_site_avatar); + holder.networkImageView.setDefaultImageResId(R.drawable.stats_icon_default_site_avatar); + rowView.setTag(holder); + } else { + holder = (StatsViewHolder) rowView.getTag(); + } + + final SingleItemModel currentRowData = list.get(position); + + String serviceName = currentRowData.getTitle(); + + // entries + holder.setEntryText(getServiceName(serviceName)); + + // totals + holder.totalsTextView.setText(FormatUtils.formatDecimal(currentRowData.getTotals())); + + // image + holder.networkImageView.setImageUrl( + GravatarUtils.fixGravatarUrl(getServiceImage(serviceName), mResourceVars.headerAvatarSizePx), + WPNetworkImageView.ImageType.BLAVATAR); + holder.networkImageView.setVisibility(View.VISIBLE); + + return rowView; + } + } + + private String getServiceImage(String service) { + String serviceIconURL; + + switch (service) { + case "facebook": + serviceIconURL = "https://secure.gravatar.com/blavatar/2343ec78a04c6ea9d80806345d31fd78?s="; + break; + case "twitter": + serviceIconURL = "https://secure.gravatar.com/blavatar/7905d1c4e12c54933a44d19fcd5f9356?s="; + break; + case "tumblr": + serviceIconURL = "https://secure.gravatar.com/blavatar/84314f01e87cb656ba5f382d22d85134?s="; + break; + case "google_plus": + serviceIconURL = "https://secure.gravatar.com/blavatar/4a4788c1dfc396b1f86355b274cc26b3?s="; + break; + case "linkedin": + serviceIconURL = "https://secure.gravatar.com/blavatar/f54db463750940e0e7f7630fe327845e?s="; + break; + case "path": + serviceIconURL = "https://secure.gravatar.com/blavatar/3a03c8ce5bf1271fb3760bb6e79b02c1?s="; + break; + default: + return null; + } + + return serviceIconURL + mResourceVars.headerAvatarSizePx; + } + + private String getServiceName(String service) { + if (service.equals("facebook")) { + return "Facebook"; + } + + if (service.equals("twitter")) { + return "Twitter"; + } + + if (service.equals("tumblr")) { + return "Tumblr"; + } + + if (service.equals("google_plus")) { + return "Google+"; + } + + if (service.equals("linkedin")) { + return "LinkedIn"; + } + + if (service.equals("path")) { + return "Path"; + } + + return StringUtils.capitalize(service); + } + + + @Override + protected int getEntryLabelResId() { + return R.string.stats_entry_publicize; + } + + @Override + protected int getTotalsLabelResId() { + return R.string.stats_totals_publicize; + } + + @Override + protected int getEmptyLabelTitleResId() { + return R.string.stats_empty_publicize; + } + + @Override + protected int getEmptyLabelDescResId() { + return R.string.stats_empty_publicize_desc; + } + + @Override + protected StatsService.StatsEndpointsEnum[] sectionsToUpdate() { + return new StatsService.StatsEndpointsEnum[]{ + StatsService.StatsEndpointsEnum.PUBLICIZE + }; + } + + @Override + public String getTitle() { + return getString(R.string.stats_view_publicize); + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsReferrersFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsReferrersFragment.java new file mode 100644 index 000000000..c94293c7b --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsReferrersFragment.java @@ -0,0 +1,340 @@ +package org.wordpress.android.ui.stats; + +import android.app.Activity; +import android.os.Bundle; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseExpandableListAdapter; + +import org.wordpress.android.R; +import org.wordpress.android.ui.stats.models.ReferrerGroupModel; +import org.wordpress.android.ui.stats.models.ReferrerResultModel; +import org.wordpress.android.ui.stats.models.ReferrersModel; +import org.wordpress.android.ui.stats.models.SingleItemModel; +import org.wordpress.android.ui.stats.service.StatsService; +import org.wordpress.android.util.FormatUtils; +import org.wordpress.android.util.GravatarUtils; +import org.wordpress.android.widgets.WPNetworkImageView; + +import java.util.ArrayList; +import java.util.List; + +public class StatsReferrersFragment extends StatsAbstractListFragment { + public static final String TAG = StatsReferrersFragment.class.getSimpleName(); + + private ReferrersModel mReferrers; + + @Override + protected boolean hasDataAvailable() { + return mReferrers != null; + } + @Override + protected void saveStatsData(Bundle outState) { + if (hasDataAvailable()) { + outState.putSerializable(ARG_REST_RESPONSE, mReferrers); + } + } + @Override + protected void restoreStatsData(Bundle savedInstanceState) { + if (savedInstanceState.containsKey(ARG_REST_RESPONSE)) { + mReferrers = (ReferrersModel) savedInstanceState.getSerializable(ARG_REST_RESPONSE); + } + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.ReferrersUpdated event) { + if (!shouldUpdateFragmentOnUpdateEvent(event)) { + return; + } + + mGroupIdToExpandedMap.clear(); + mReferrers = event.mReferrers; + + updateUI(); + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.SectionUpdateError event) { + if (!shouldUpdateFragmentOnErrorEvent(event)) { + return; + } + + mReferrers = null; + mGroupIdToExpandedMap.clear(); + showErrorUI(event.mError); + } + + @Override + protected void updateUI() { + if (!isAdded()) { + return; + } + + if (hasReferrers()) { + BaseExpandableListAdapter adapter = new MyExpandableListAdapter(getActivity(), getReferrersGroups()); + StatsUIHelper.reloadGroupViews(getActivity(), adapter, mGroupIdToExpandedMap, mList, getMaxNumberOfItemsToShowInList()); + showHideNoResultsUI(false); + } else { + showHideNoResultsUI(true); + } + } + + private boolean hasReferrers() { + return mReferrers != null + && mReferrers.getGroups() != null + && mReferrers.getGroups().size() > 0; + } + + private List<ReferrerGroupModel> getReferrersGroups() { + if (!hasReferrers()) { + return new ArrayList<ReferrerGroupModel>(0); + } + return mReferrers.getGroups(); + } + + @Override + protected boolean isViewAllOptionAvailable() { + return hasReferrers() && getReferrersGroups().size() > MAX_NUM_OF_ITEMS_DISPLAYED_IN_LIST; + } + + @Override + protected boolean isExpandableList() { + return true; + } + + @Override + protected StatsService.StatsEndpointsEnum[] sectionsToUpdate() { + return new StatsService.StatsEndpointsEnum[]{ + StatsService.StatsEndpointsEnum.REFERRERS + }; + } + + @Override + protected int getEntryLabelResId() { + return R.string.stats_entry_referrers; + } + @Override + protected int getTotalsLabelResId() { + return R.string.stats_totals_views; + } + @Override + protected int getEmptyLabelTitleResId() { + return R.string.stats_empty_referrers_title; + } + @Override + protected int getEmptyLabelDescResId() { + return R.string.stats_empty_referrers_desc; + } + + private class MyExpandableListAdapter extends BaseExpandableListAdapter { + public final LayoutInflater inflater; + public final Activity act; + private final List<ReferrerGroupModel> groups; + private final List<List<MyChildModel>> children; + + public MyExpandableListAdapter(Activity act, List<ReferrerGroupModel> groups) { + this.groups = groups; + this.inflater = LayoutInflater.from(act); + this.act = act; + + // The code below flattens the 3-levels tree of children to a 2-levels structure + // that will be used later to populate the UI + this.children = new ArrayList<>(groups.size()); + // pre-populate the structure with null values + for (int i = 0; i < groups.size(); i++) { + this.children.add(null); + } + + for (int i = 0; i < groups.size(); i++) { + ReferrerGroupModel currentGroup = groups.get(i); + List<MyChildModel> currentGroupChildren = new ArrayList<>(); + List<ReferrerResultModel> childrenOfLevelOne = currentGroup.getResults(); + if (childrenOfLevelOne != null) { + // Children at first level could be a single item or another tree + // Levels 2 children are skipped in the UI. + for (ReferrerResultModel singleLevelOneChild : childrenOfLevelOne) { + // Use all the info given in the first level child. + MyChildModel myChild = new MyChildModel(); + myChild.icon = singleLevelOneChild.getIcon(); + myChild.url = singleLevelOneChild.getUrl(); + myChild.name = singleLevelOneChild.getName(); + myChild.views = singleLevelOneChild.getViews(); + + // read the URL from the first second-level child if available. + List<SingleItemModel> secondLevelChildren = singleLevelOneChild.getChildren(); + if (secondLevelChildren != null && secondLevelChildren.size() > 0) { + SingleItemModel firstThirdLevelChild = secondLevelChildren.get(0); + myChild.url = firstThirdLevelChild.getUrl(); + } + currentGroupChildren.add(myChild); + } + } + this.children.set(i, currentGroupChildren); + } + } + + private final class MyChildModel { + String name; + int views; + String url; + String icon; + } + + @Override + public Object getChild(int groupPosition, int childPosition) { + List<MyChildModel> currentGroupChildren = children.get(groupPosition); + return currentGroupChildren.get(childPosition); + } + + @Override + public long getChildId(int groupPosition, int childPosition) { + return 0; + } + + @Override + public View getChildView(int groupPosition, final int childPosition, + boolean isLastChild, View convertView, ViewGroup parent) { + + final MyChildModel currentChild = (MyChildModel) getChild(groupPosition, childPosition); + + if (convertView == null) { + convertView = inflater.inflate(R.layout.stats_list_cell, parent, false); + // configure view holder + StatsViewHolder viewHolder = new StatsViewHolder(convertView); + convertView.setTag(viewHolder); + } + + final StatsViewHolder holder = (StatsViewHolder) convertView.getTag(); + + String name = currentChild.name; + int views = currentChild.views; + + holder.chevronImageView.setVisibility(View.GONE); + holder.linkImageView.setVisibility(TextUtils.isEmpty(currentChild.url) ? View.GONE : View.VISIBLE); + holder.setEntryTextOrLink(currentChild.url, name); + + // totals + holder.totalsTextView.setText(FormatUtils.formatDecimal(views)); + + // site icon + holder.networkImageView.setVisibility(View.GONE); + if (!TextUtils.isEmpty(currentChild.icon)) { + holder.networkImageView.setImageUrl( + GravatarUtils.fixGravatarUrl(currentChild.icon, mResourceVars.headerAvatarSizePx), + WPNetworkImageView.ImageType.GONE_UNTIL_AVAILABLE); + } + + // no more btm + holder.imgMore.setVisibility(View.GONE); + + return convertView; + } + + @Override + public int getChildrenCount(int groupPosition) { + List<MyChildModel> currentGroupChildren = children.get(groupPosition); + if (currentGroupChildren == null) { + return 0; + } else { + return currentGroupChildren.size(); + } + } + + @Override + public Object getGroup(int groupPosition) { + return groups.get(groupPosition); + } + + @Override + public int getGroupCount() { + return groups.size(); + } + + + @Override + public long getGroupId(int groupPosition) { + return 0; + } + + @Override + public View getGroupView(final int groupPosition, boolean isExpanded, + View convertView, ViewGroup parent) { + + final StatsViewHolder holder; + if (convertView == null) { + convertView = inflater.inflate(R.layout.stats_list_cell, parent, false); + holder = new StatsViewHolder(convertView); + convertView.setTag(holder); + } else { + holder = (StatsViewHolder) convertView.getTag(); + } + + final ReferrerGroupModel group = (ReferrerGroupModel) getGroup(groupPosition); + + String name = group.getName(); + int total = group.getTotal(); + String url = group.getUrl(); + String icon = group.getIcon(); + int children = getChildrenCount(groupPosition); + + if (children > 0) { + holder.setEntryText(name, getResources().getColor(R.color.stats_link_text_color)); + } else { + holder.setEntryTextOrLink(url, name); + } + + // totals + holder.totalsTextView.setText(FormatUtils.formatDecimal(total)); + + // Site icon + holder.networkImageView.setVisibility(View.GONE); + if (!TextUtils.isEmpty(icon)) { + holder.networkImageView.setImageUrl( + GravatarUtils.fixGravatarUrl(icon, mResourceVars.headerAvatarSizePx), + WPNetworkImageView.ImageType.GONE_UNTIL_AVAILABLE); + } + + if (children == 0) { + holder.showLinkIcon(); + } else { + holder.showChevronIcon(); + } + + // Setup the spam button + if (ReferrerSpamHelper.isSpamActionAvailable(group)) { + holder.imgMore.setVisibility(View.VISIBLE); + holder.imgMore.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + ReferrerSpamHelper rp = new ReferrerSpamHelper(act); + rp.showPopup(holder.imgMore, group); + } + }); + + } else { + holder.imgMore.setVisibility(View.GONE); + holder.imgMore.setClickable(false); + } + + return convertView; + } + + @Override + public boolean hasStableIds() { + return false; + } + + @Override + public boolean isChildSelectable(int groupPosition, int childPosition) { + return false; + } + + } + + @Override + public String getTitle() { + return getString(R.string.stats_view_referrers); + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsResourceVars.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsResourceVars.java new file mode 100644 index 000000000..5bdbda53e --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsResourceVars.java @@ -0,0 +1,19 @@ +package org.wordpress.android.ui.stats; + +import android.content.Context; +import android.content.res.Resources; + +import org.wordpress.android.R; + +/* + * class which holds all resource-based variables used in Stats + */ +class StatsResourceVars { + final int headerAvatarSizePx; + + StatsResourceVars(Context context) { + Resources resources = context.getResources(); + + headerAvatarSizePx = resources.getDimensionPixelSize(R.dimen.avatar_sz_small); + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsSearchTermsFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsSearchTermsFragment.java new file mode 100644 index 000000000..5289b2e0d --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsSearchTermsFragment.java @@ -0,0 +1,238 @@ +package org.wordpress.android.ui.stats; + +import android.app.Activity; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; + +import org.wordpress.android.R; +import org.wordpress.android.ui.stats.models.SearchTermModel; +import org.wordpress.android.ui.stats.models.SearchTermsModel; +import org.wordpress.android.ui.stats.service.StatsService; +import org.wordpress.android.util.FormatUtils; + +import java.util.ArrayList; +import java.util.List; + + +public class StatsSearchTermsFragment extends StatsAbstractListFragment { + public static final String TAG = StatsSearchTermsFragment.class.getSimpleName(); + + private final static String UNKNOWN_SEARCH_TERMS_HELP_PAGE = "http://en.support.wordpress.com/stats/#search-engine-terms"; + + private SearchTermsModel mSearchTerms; + + @Override + protected boolean hasDataAvailable() { + return mSearchTerms != null; + } + @Override + protected void saveStatsData(Bundle outState) { + if (hasDataAvailable()) { + outState.putSerializable(ARG_REST_RESPONSE, mSearchTerms); + } + } + @Override + protected void restoreStatsData(Bundle savedInstanceState) { + if (savedInstanceState.containsKey(ARG_REST_RESPONSE)) { + mSearchTerms = (SearchTermsModel) savedInstanceState.getSerializable(ARG_REST_RESPONSE); + } + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.SearchTermsUpdated event) { + if (!shouldUpdateFragmentOnUpdateEvent(event)) { + return; + } + + mSearchTerms = event.mSearchTerms; + updateUI(); + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.SectionUpdateError event) { + if (!shouldUpdateFragmentOnErrorEvent(event)) { + return; + } + + mSearchTerms = null; + showErrorUI(event.mError); + } + + @Override + protected void updateUI() { + if (!isAdded()) { + return; + } + + if (hasSearchTerms()) { + + /** + * At this point we can have: + * - A list of search terms + * - A list of search terms + Encrypted item + * - Encrypted item only + * + * We want to display max 10 items regardless the kind of the items, AND Encrypted + * must be present if available. + * + * We need to do some counts then... + */ + + List<SearchTermModel> originalSearchTermList = mSearchTerms.getSearchTerms(); + List<SearchTermModel> mySearchTermList; + if (originalSearchTermList == null) { + // No clear-text search terms. we know we have the encrypted search terms item available + mySearchTermList = new ArrayList<>(0); + } else { + // Make sure the list has MAX 9 items if the "Encrypted" is available + // we want to show exactly 10 items per module + if (mSearchTerms.getEncryptedSearchTerms() > 0 && originalSearchTermList.size() > getMaxNumberOfItemsToShowInList() - 1) { + mySearchTermList = new ArrayList<>(); + int minIndex = Math.min(originalSearchTermList.size(), getMaxNumberOfItemsToShowInList() - 1); + for (int i = 0; i < minIndex; i++) { + mySearchTermList.add(originalSearchTermList.get(i)); + } + } else { + mySearchTermList = originalSearchTermList; + } + } + ArrayAdapter adapter = new SearchTermsAdapter(getActivity(), mySearchTermList, mSearchTerms.getEncryptedSearchTerms()); + StatsUIHelper.reloadLinearLayout(getActivity(), adapter, mList, getMaxNumberOfItemsToShowInList()); + showHideNoResultsUI(false); + } else { + showHideNoResultsUI(true); + } + } + + private boolean hasSearchTerms() { + return mSearchTerms != null + && ((mSearchTerms.getSearchTerms() != null && mSearchTerms.getSearchTerms().size() > 0) + || mSearchTerms.getEncryptedSearchTerms() > 0 + ); + } + + @Override + protected boolean isViewAllOptionAvailable() { + if (!hasSearchTerms()) { + return false; + } + + + int total = mSearchTerms.getSearchTerms() != null ? mSearchTerms.getSearchTerms().size() : 0; + // If "Encrypted" is available we only have 9 items of clear text terms in the list + if (mSearchTerms.getEncryptedSearchTerms() > 0) { + return total > MAX_NUM_OF_ITEMS_DISPLAYED_IN_LIST - 1; + } else { + return total > MAX_NUM_OF_ITEMS_DISPLAYED_IN_LIST; + } + } + + @Override + protected boolean isExpandableList() { + return false; + } + + private class SearchTermsAdapter extends ArrayAdapter<SearchTermModel> { + + private final List<SearchTermModel> list; + private final LayoutInflater inflater; + private final int encryptedSearchTerms; + + public SearchTermsAdapter(Activity context, List<SearchTermModel> list, int encryptedSearchTerms) { + super(context, R.layout.stats_list_cell, list); + this.list = list; + this.encryptedSearchTerms = encryptedSearchTerms; + this.inflater = LayoutInflater.from(context); + } + + @Override + public int getCount() { + return super.getCount() + (encryptedSearchTerms > 0 ? 1 : 0); + } + + @Override + public SearchTermModel getItem(int position) { + // If it's an element in the list returns it, otherwise it's the position of "Encrypted" + if (position < super.getCount()) { + return super.getItem(position); + } + + return new SearchTermModel("", null, "Unknown Search Terms", encryptedSearchTerms, true); + } + + @Override + public int getPosition(SearchTermModel item) { + if (item.isEncriptedTerms()) { + return super.getCount(); // "Encrypted" is always at the end of the list + } + + return super.getPosition(item); + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + View rowView = convertView; + // reuse views + if (rowView == null) { + rowView = inflater.inflate(R.layout.stats_list_cell, parent, false); + // configure view holder + StatsViewHolder viewHolder = new StatsViewHolder(rowView); + rowView.setTag(viewHolder); + } + + final SearchTermModel currentRowData = this.getItem(position); + StatsViewHolder holder = (StatsViewHolder) rowView.getTag(); + + String term = currentRowData.getTitle(); + + if (currentRowData.isEncriptedTerms()) { + holder.setEntryTextOrLink(UNKNOWN_SEARCH_TERMS_HELP_PAGE, getString(R.string.stats_search_terms_unknown_search_terms)); + } else { + holder.setEntryText(term, getResources().getColor(R.color.stats_text_color)); + } + + // totals + holder.totalsTextView.setText(FormatUtils.formatDecimal(currentRowData.getTotals())); + + // image + holder.networkImageView.setVisibility(View.GONE); + + return rowView; + } + } + + @Override + protected int getEntryLabelResId() { + return R.string.stats_entry_search_terms; + } + + @Override + protected int getTotalsLabelResId() { + return R.string.stats_totals_views; + } + + @Override + protected int getEmptyLabelTitleResId() { + return R.string.stats_empty_search_terms; + } + + @Override + protected int getEmptyLabelDescResId() { + return R.string.stats_empty_search_terms_desc; + } + + @Override + protected StatsService.StatsEndpointsEnum[] sectionsToUpdate() { + return new StatsService.StatsEndpointsEnum[]{ + StatsService.StatsEndpointsEnum.SEARCH_TERMS + }; + } + + @Override + public String getTitle() { + return getString(R.string.stats_view_search_terms); + } +}
\ No newline at end of file diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsSingleItemDetailsActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsSingleItemDetailsActivity.java new file mode 100644 index 000000000..0ec8970cd --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsSingleItemDetailsActivity.java @@ -0,0 +1,907 @@ +package org.wordpress.android.ui.stats; + +import android.app.Activity; +import android.content.Context; +import android.graphics.Color; +import android.os.Bundle; +import android.os.Handler; +import android.support.v7.app.ActionBar; +import android.support.v7.app.AppCompatActivity; +import android.util.SparseBooleanArray; +import android.view.LayoutInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseExpandableListAdapter; +import android.widget.LinearLayout; +import android.widget.RelativeLayout; +import android.widget.TextView; +import android.widget.Toast; + +import com.android.volley.NoConnectionError; +import com.android.volley.VolleyError; +import com.jjoe64.graphview.GraphView; +import com.jjoe64.graphview.GraphViewSeries; +import com.wordpress.rest.RestRequest; + +import org.json.JSONException; +import org.json.JSONObject; +import org.wordpress.android.R; +import org.wordpress.android.WordPress; +import org.wordpress.android.analytics.AnalyticsTracker; +import org.wordpress.android.networking.RestClientUtils; +import org.wordpress.android.ui.ActivityId; +import org.wordpress.android.ui.stats.models.PostViewsModel; +import org.wordpress.android.ui.stats.models.VisitModel; +import org.wordpress.android.util.AnalyticsUtils; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.DisplayUtils; +import org.wordpress.android.util.FormatUtils; +import org.wordpress.android.util.NetworkUtils; +import org.wordpress.android.util.ToastUtils; +import org.wordpress.android.util.helpers.SwipeToRefreshHelper; +import org.wordpress.android.util.widgets.CustomSwipeRefreshLayout; + +import java.lang.ref.WeakReference; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadPoolExecutor; + + +/** + * Single item details activity. + */ +public class StatsSingleItemDetailsActivity extends AppCompatActivity + implements StatsBarGraph.OnGestureListener{ + + public static final String ARG_REMOTE_BLOG_ID = "ARG_REMOTE_BLOG_ID"; + public static final String ARG_REMOTE_ITEM_ID = "ARG_REMOTE_ITEM_ID"; + public static final String ARG_REMOTE_ITEM_TYPE = "ARG_REMOTE_ITEM_TYPE"; + public static final String ARG_ITEM_TITLE = "ARG_ITEM_TITLE"; + public static final String ARG_ITEM_URL = "ARG_ITEM_URL"; + private static final String ARG_REST_RESPONSE = "ARG_REST_RESPONSE"; + private static final String ARG_SELECTED_GRAPH_BAR = "ARG_SELECTED_GRAPH_BAR"; + private static final String ARG_PREV_NUMBER_OF_BARS = "ARG_PREV_NUMBER_OF_BARS"; + private static final String SAVED_STATS_SCROLL_POSITION = "SAVED_STATS_SCROLL_POSITION"; + + private boolean mIsUpdatingStats; + private SwipeToRefreshHelper mSwipeToRefreshHelper; + private ScrollViewExt mOuterScrollView; + + private final Handler mHandler = new Handler(); + + private LinearLayout mGraphContainer; + private TextView mStatsViewsLabel; + private TextView mStatsViewsTotals; + + private LinearLayout mMonthsAndYearsModule; + private LinearLayout mMonthsAndYearsList; + private RelativeLayout mMonthsAndYearsHeader; + private LinearLayout mMonthsAndYearsEmptyPlaceholder; + + private LinearLayout mAveragesModule; + private LinearLayout mAveragesList; + private RelativeLayout mAveragesHeader; + private LinearLayout mAveragesEmptyPlaceholder; + + private LinearLayout mRecentWeeksModule; + private LinearLayout mRecentWeeksList; + private RelativeLayout mRecentWeeksHeader; + private LinearLayout mRecentWeeksEmptyPlaceholder; + private String mRemoteBlogID, mRemoteItemID, mRemoteItemType, mItemTitle, mItemURL; + private PostViewsModel mRestResponseParsed; + private int mSelectedBarGraphIndex = -1; + private int mPrevNumberOfBarsGraph = -1; + + private SparseBooleanArray mYearsIdToExpandedMap; + private SparseBooleanArray mAveragesIdToExpandedMap; + private SparseBooleanArray mRecentWeeksIdToExpandedMap; + + private static final String ARG_YEARS_EXPANDED_ROWS = "ARG_YEARS_EXPANDED_ROWS"; + private static final String ARG_AVERAGES_EXPANDED_ROWS = "ARG_AVERAGES_EXPANDED_ROWS"; + private static final String ARG_RECENT_EXPANDED_ROWS = "ARG_RECENT_EXPANDED_ROWS"; + + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.stats_activity_single_post_details); + + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(true); + } + + // pull to refresh setup + mSwipeToRefreshHelper = new SwipeToRefreshHelper(this, (CustomSwipeRefreshLayout) findViewById(R.id.ptr_layout), + new SwipeToRefreshHelper.RefreshListener() { + @Override + public void onRefreshStarted() { + if (!NetworkUtils.checkConnection(getBaseContext())) { + mSwipeToRefreshHelper.setRefreshing(false); + return; + } + refreshStats(); + } + } + ); + + TextView mStatsForLabel = (TextView) findViewById(R.id.stats_summary_title); + mGraphContainer = (LinearLayout) findViewById(R.id.stats_bar_chart_fragment_container); + mStatsViewsLabel = (TextView) findViewById(R.id.stats_views_label); + mStatsViewsTotals = (TextView) findViewById(R.id.stats_views_totals); + + mMonthsAndYearsModule = (LinearLayout) findViewById(R.id.stats_months_years_module); + mMonthsAndYearsHeader = (RelativeLayout) findViewById(R.id.stats_months_years_header); + mMonthsAndYearsList = (LinearLayout) findViewById(R.id.stats_months_years_list_linearlayout); + mMonthsAndYearsEmptyPlaceholder = (LinearLayout) findViewById(R.id.stats_months_years_empty_module_placeholder); + + mAveragesModule = (LinearLayout) findViewById(R.id.stats_averages_module); + mAveragesHeader = (RelativeLayout) findViewById(R.id.stats_averages_list_header); + mAveragesList = (LinearLayout) findViewById(R.id.stats_averages_list_linearlayout); + mAveragesEmptyPlaceholder = (LinearLayout) findViewById(R.id.stats_averages_empty_module_placeholder); + + mRecentWeeksModule = (LinearLayout) findViewById(R.id.stats_recent_weeks_module); + mRecentWeeksHeader = (RelativeLayout) findViewById(R.id.stats_recent_weeks_list_header); + mRecentWeeksList = (LinearLayout) findViewById(R.id.stats_recent_weeks_list_linearlayout); + mRecentWeeksEmptyPlaceholder = (LinearLayout) findViewById(R.id.stats_recent_weeks_empty_module_placeholder); + + mYearsIdToExpandedMap = new SparseBooleanArray(); + mAveragesIdToExpandedMap = new SparseBooleanArray(); + mRecentWeeksIdToExpandedMap = new SparseBooleanArray(); + + setTitle(R.string.stats); + mOuterScrollView = (ScrollViewExt) findViewById(R.id.scroll_view_stats); + + if (savedInstanceState != null) { + mRemoteItemID = savedInstanceState.getString(ARG_REMOTE_ITEM_ID); + mRemoteBlogID = savedInstanceState.getString(ARG_REMOTE_BLOG_ID); + mRemoteItemType = savedInstanceState.getString(ARG_REMOTE_ITEM_TYPE); + mItemTitle = savedInstanceState.getString(ARG_ITEM_TITLE); + mItemURL = savedInstanceState.getString(ARG_ITEM_URL); + mRestResponseParsed = (PostViewsModel) savedInstanceState.getSerializable(ARG_REST_RESPONSE); + mSelectedBarGraphIndex = savedInstanceState.getInt(ARG_SELECTED_GRAPH_BAR, -1); + mPrevNumberOfBarsGraph = savedInstanceState.getInt(ARG_PREV_NUMBER_OF_BARS, -1); + + final int yScrollPosition = savedInstanceState.getInt(SAVED_STATS_SCROLL_POSITION); + if(yScrollPosition != 0) { + mOuterScrollView.postDelayed(new Runnable() { + public void run() { + if (!isFinishing()) { + mOuterScrollView.scrollTo(0, yScrollPosition); + } + } + }, StatsConstants.STATS_SCROLL_TO_DELAY); + } + if (savedInstanceState.containsKey(ARG_AVERAGES_EXPANDED_ROWS)) { + mAveragesIdToExpandedMap = savedInstanceState.getParcelable(ARG_AVERAGES_EXPANDED_ROWS); + } + if (savedInstanceState.containsKey(ARG_RECENT_EXPANDED_ROWS)) { + mRecentWeeksIdToExpandedMap = savedInstanceState.getParcelable(ARG_RECENT_EXPANDED_ROWS); + } + if (savedInstanceState.containsKey(ARG_YEARS_EXPANDED_ROWS)) { + mYearsIdToExpandedMap = savedInstanceState.getParcelable(ARG_YEARS_EXPANDED_ROWS); + } + } else if (getIntent() != null && getIntent().getExtras() != null) { + Bundle extras = getIntent().getExtras(); + mRemoteItemID = extras.getString(ARG_REMOTE_ITEM_ID); + mRemoteBlogID = extras.getString(ARG_REMOTE_BLOG_ID); + mRemoteItemType = extras.getString(ARG_REMOTE_ITEM_TYPE); + mItemTitle = extras.getString(ARG_ITEM_TITLE); + mItemURL = extras.getString(ARG_ITEM_URL); + mRestResponseParsed = (PostViewsModel) extras.getSerializable(ARG_REST_RESPONSE); + mSelectedBarGraphIndex = extras.getInt(ARG_SELECTED_GRAPH_BAR, -1); + } + + if (mRemoteBlogID == null || mRemoteItemID == null) { + Toast.makeText(this, R.string.stats_generic_error, Toast.LENGTH_LONG).show(); + finish(); + return; + } + + if (savedInstanceState == null) { + AnalyticsUtils.trackWithBlogDetails( + AnalyticsTracker.Stat.STATS_SINGLE_POST_ACCESSED, + mRemoteBlogID + ); + } + + // Setup the main top label that opens the post in the Reader where possible + if (mItemTitle != null || mItemURL != null) { + mStatsForLabel.setVisibility(View.VISIBLE); + mStatsForLabel.setText(mItemTitle != null ? mItemTitle : mItemURL ); + // make the label clickable if the URL is available + if (mItemURL != null) { + mStatsForLabel.setTextColor(getResources().getColor(R.color.stats_link_text_color)); + mStatsForLabel.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + final Context ctx = v.getContext(); + StatsUtils.openPostInReaderOrInAppWebview(ctx, + mRemoteBlogID, + mRemoteItemID, + mRemoteItemType, + mItemURL); + } + }); + } else { + mStatsForLabel.setTextColor(getResources().getColor(R.color.grey_darken_20)); + } + } else { + mStatsForLabel.setVisibility(View.GONE); + } + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + outState.putInt(ARG_SELECTED_GRAPH_BAR, mSelectedBarGraphIndex); + outState.putInt(ARG_PREV_NUMBER_OF_BARS, mPrevNumberOfBarsGraph); + outState.putString(ARG_REMOTE_BLOG_ID, mRemoteBlogID); + outState.putString(ARG_REMOTE_ITEM_ID, mRemoteItemID); + outState.putString(ARG_REMOTE_ITEM_TYPE, mRemoteItemType); + outState.putString(ARG_ITEM_TITLE, mItemTitle); + outState.putString(ARG_ITEM_URL, mItemURL); + + outState.putSerializable(ARG_REST_RESPONSE, mRestResponseParsed); + if (mOuterScrollView.getScrollY() != 0) { + outState.putInt(SAVED_STATS_SCROLL_POSITION, mOuterScrollView.getScrollY()); + } + + if (mAveragesIdToExpandedMap.size() > 0){ + outState.putParcelable(ARG_AVERAGES_EXPANDED_ROWS, new SparseBooleanArrayParcelable(mAveragesIdToExpandedMap)); + } + if (mRecentWeeksIdToExpandedMap.size() > 0) { + outState.putParcelable(ARG_RECENT_EXPANDED_ROWS, new SparseBooleanArrayParcelable(mRecentWeeksIdToExpandedMap)); + } + if (mYearsIdToExpandedMap.size() > 0) { + outState.putParcelable(ARG_YEARS_EXPANDED_ROWS, new SparseBooleanArrayParcelable(mYearsIdToExpandedMap)); + } + + super.onSaveInstanceState(outState); + } + + @Override + protected void onResume() { + super.onResume(); + if (mRestResponseParsed == null) { + // check if network is available, if not shows the empty UI immediately + if (!NetworkUtils.checkConnection(this)) { + mSwipeToRefreshHelper.setRefreshing(false); + setupEmptyUI(); + } else { + setupEmptyGraph(""); + showHideEmptyModulesIndicator(true); + refreshStats(); + } + } else { + updateUI(); + } + ActivityId.trackLastActivity(ActivityId.STATS_POST_DETAILS); + } + + @Override + protected void onPause() { + super.onPause(); + mIsUpdatingStats = false; + mSwipeToRefreshHelper.setRefreshing(false); + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + onBackPressed(); + return true; + default: + return super.onOptionsItemSelected(item); + } + } + + private void refreshStats() { + + if (mIsUpdatingStats) { + AppLog.w(AppLog.T.STATS, "stats details are already updating for the following postID " + + mRemoteItemID + ", refresh cancelled."); + return; + } + + if (!NetworkUtils.checkConnection(this)) { + mSwipeToRefreshHelper.setRefreshing(false); + return; + } + + final RestClientUtils restClientUtils = WordPress.getRestClientUtilsV1_1(); + + + // View and visitor counts for a site + final String singlePostRestPath = String.format( + "/sites/%s/stats/post/%s", mRemoteBlogID, mRemoteItemID); + + AppLog.d(AppLog.T.STATS, "Enqueuing the following Stats request " + singlePostRestPath); + + RestBatchCallListener vListener = new RestBatchCallListener(this); + restClientUtils.get(singlePostRestPath, vListener, vListener); + + mIsUpdatingStats = true; + mSwipeToRefreshHelper.setRefreshing(true); + } + + private void showHideEmptyModulesIndicator(boolean show) { + if (isFinishing()) { + return; + } + + mMonthsAndYearsModule.setVisibility(View.VISIBLE); + mRecentWeeksModule.setVisibility(View.VISIBLE); + mAveragesModule.setVisibility(View.VISIBLE); + + mMonthsAndYearsHeader.setVisibility(show ? View.GONE : View.VISIBLE); + mRecentWeeksHeader.setVisibility(show ? View.GONE : View.VISIBLE); + mAveragesHeader.setVisibility(show ? View.GONE : View.VISIBLE); + + mMonthsAndYearsList.setVisibility(show ? View.GONE : View.VISIBLE); + mAveragesList.setVisibility(show ? View.GONE : View.VISIBLE); + mRecentWeeksList.setVisibility(show ? View.GONE : View.VISIBLE); + + mMonthsAndYearsEmptyPlaceholder.setVisibility(show ? View.VISIBLE : View.GONE); + mRecentWeeksEmptyPlaceholder.setVisibility(show ? View.VISIBLE : View.GONE); + mAveragesEmptyPlaceholder.setVisibility(show ? View.VISIBLE : View.GONE); + } + + private void setupEmptyUI() { + if (isFinishing()) { + return; + } + + setupEmptyGraph(null); + + mMonthsAndYearsModule.setVisibility(View.GONE); + mRecentWeeksModule.setVisibility(View.GONE); + mAveragesModule.setVisibility(View.GONE); + + mRecentWeeksIdToExpandedMap.clear(); + mAveragesIdToExpandedMap.clear(); + mYearsIdToExpandedMap.clear(); + } + + private void setupEmptyGraph(String emptyLabel) { + if (isFinishing()) { + return; + } + Context context = mGraphContainer.getContext(); + if (context != null) { + LayoutInflater inflater = LayoutInflater.from(context); + View emptyBarGraphView = inflater.inflate(R.layout.stats_bar_graph_empty, mGraphContainer, false); + if (emptyLabel != null) { + final TextView emptyLabelField = (TextView) emptyBarGraphView.findViewById(R.id.stats_bar_graph_empty_label); + emptyLabelField.setText(emptyLabel); + } + mGraphContainer.removeAllViews(); + mGraphContainer.addView(emptyBarGraphView); + } + mStatsViewsLabel.setText(""); + mStatsViewsTotals.setText(""); + } + + private VisitModel[] getDataToShowOnGraph () { + if (mRestResponseParsed == null) { + return new VisitModel[0]; + } + + final VisitModel[] dayViews = mRestResponseParsed.getDayViews(); + if (dayViews == null) { + return new VisitModel[0]; + } + + int numPoints = Math.min(StatsUIHelper.getNumOfBarsToShow(), dayViews.length); + int currentPointIndex = numPoints - 1; + VisitModel[] visitModels = new VisitModel[numPoints]; + + for (int i = dayViews.length - 1; i >= 0 && currentPointIndex >= 0; i--) { + visitModels[currentPointIndex] = dayViews[i]; + currentPointIndex--; + } + + return visitModels; + } + + private void updateUI() { + if (isFinishing()) { + return; + } + final VisitModel[] dataToShowOnGraph = getDataToShowOnGraph(); + + if (dataToShowOnGraph == null || dataToShowOnGraph.length == 0) { + setupEmptyUI(); + return; + } + + final String[] horLabels = new String[dataToShowOnGraph.length]; + String[] mStatsDate = new String[dataToShowOnGraph.length]; + GraphView.GraphViewData[] views = new GraphView.GraphViewData[dataToShowOnGraph.length]; + + for (int i = 0; i < dataToShowOnGraph.length; i++) { + int currentItemValue = dataToShowOnGraph[i].getViews(); + views[i] = new GraphView.GraphViewData(i, currentItemValue); + + String currentItemStatsDate = dataToShowOnGraph[i].getPeriod(); + horLabels[i] = StatsUtils.parseDate( + currentItemStatsDate, + StatsConstants.STATS_INPUT_DATE_FORMAT, + StatsConstants.STATS_OUTPUT_DATE_MONTH_SHORT_DAY_SHORT_FORMAT + ); + mStatsDate[i] = currentItemStatsDate; + } + + GraphViewSeries mCurrentSeriesOnScreen = new GraphViewSeries(views); + mCurrentSeriesOnScreen.getStyle().color = getResources().getColor(R.color.stats_bar_graph_main_series); + mCurrentSeriesOnScreen.getStyle().highlightColor = getResources().getColor(R.color.stats_bar_graph_main_series_highlight); + mCurrentSeriesOnScreen.getStyle().outerhighlightColor = getResources().getColor(R.color.stats_bar_graph_outer_highlight); + mCurrentSeriesOnScreen.getStyle().padding = DisplayUtils.dpToPx(this, 5); + + StatsBarGraph mGraphView; + if (mGraphContainer.getChildCount() >= 1 && mGraphContainer.getChildAt(0) instanceof GraphView) { + mGraphView = (StatsBarGraph) mGraphContainer.getChildAt(0); + } else { + mGraphContainer.removeAllViews(); + mGraphView = new StatsBarGraph(this); + mGraphContainer.addView(mGraphView); + } + + + mGraphView.removeAllSeries(); + mGraphView.addSeries(mCurrentSeriesOnScreen); + //mGraphView.getGraphViewStyle().setNumHorizontalLabels(getNumOfHorizontalLabels(dataToShowOnGraph.length)); + mGraphView.getGraphViewStyle().setNumHorizontalLabels(dataToShowOnGraph.length); + mGraphView.getGraphViewStyle().setMaxColumnWidth( + DisplayUtils.dpToPx(this, StatsConstants.STATS_GRAPH_BAR_MAX_COLUMN_WIDTH_DP) + ); + mGraphView.setHorizontalLabels(horLabels); + mGraphView.setGestureListener(this); + + // Reset the bar selected upon rotation of the device when the no. of bars can change with orientation. + // Only happens on 720DP tablets + if (mPrevNumberOfBarsGraph != -1 && mPrevNumberOfBarsGraph != dataToShowOnGraph.length) { + mSelectedBarGraphIndex = dataToShowOnGraph.length - 1; + } else { + mSelectedBarGraphIndex = (mSelectedBarGraphIndex != -1) ? mSelectedBarGraphIndex : dataToShowOnGraph.length - 1; + } + + mGraphView.highlightBar(mSelectedBarGraphIndex); + mPrevNumberOfBarsGraph = dataToShowOnGraph.length; + + setMainViewsLabel( + StatsUtils.parseDate( + mStatsDate[mSelectedBarGraphIndex], + StatsConstants.STATS_INPUT_DATE_FORMAT, + StatsConstants.STATS_OUTPUT_DATE_MONTH_LONG_DAY_SHORT_FORMAT + ), + dataToShowOnGraph[mSelectedBarGraphIndex].getViews() + ); + + showHideEmptyModulesIndicator(false); + + mMonthsAndYearsList.setVisibility(View.VISIBLE); + List<PostViewsModel.Year> years = mRestResponseParsed.getYears(); + MonthsAndYearsListAdapter monthsAndYearsListAdapter = new MonthsAndYearsListAdapter(this, years, mRestResponseParsed.getHighestMonth()); + StatsUIHelper.reloadGroupViews(this, monthsAndYearsListAdapter, mYearsIdToExpandedMap, mMonthsAndYearsList); + + mAveragesList.setVisibility(View.VISIBLE); + List<PostViewsModel.Year> averages = mRestResponseParsed.getAverages(); + MonthsAndYearsListAdapter averagesListAdapter = new MonthsAndYearsListAdapter(this, averages, mRestResponseParsed.getHighestDayAverage()); + StatsUIHelper.reloadGroupViews(this, averagesListAdapter, mAveragesIdToExpandedMap, mAveragesList); + + mRecentWeeksList.setVisibility(View.VISIBLE); + List<PostViewsModel.Week> recentWeeks = mRestResponseParsed.getWeeks(); + RecentWeeksListAdapter recentWeeksListAdapter = new RecentWeeksListAdapter(this, recentWeeks, mRestResponseParsed.getHighestWeekAverage()); + StatsUIHelper.reloadGroupViews(this, recentWeeksListAdapter, mRecentWeeksIdToExpandedMap, mRecentWeeksList); + } + + + private void setMainViewsLabel(String dateFormatted, int totals) { + mStatsViewsLabel.setText(getString(R.string.stats_views) + ": " + + dateFormatted); + mStatsViewsTotals.setText(FormatUtils.formatDecimal(totals)); + } + + + private class RecentWeeksListAdapter extends BaseExpandableListAdapter { + public static final String GROUP_DATE_FORMAT = "MMM dd"; + public final LayoutInflater inflater; + private final List<PostViewsModel.Week> groups; + private final int maxReachedValue; + + public RecentWeeksListAdapter(Context context, List<PostViewsModel.Week> groups, int maxReachedValue) { + this.groups = groups; + this.inflater = LayoutInflater.from(context); + this.maxReachedValue = maxReachedValue; + } + + @Override + public Object getChild(int groupPosition, int childPosition) { + PostViewsModel.Week currentWeek = groups.get(groupPosition); + return currentWeek.getDays().get(childPosition); + } + + @Override + public long getChildId(int groupPosition, int childPosition) { + return 0; + } + + @Override + public View getChildView(int groupPosition, final int childPosition, + boolean isLastChild, View convertView, ViewGroup parent) { + + final PostViewsModel.Day currentDay = (PostViewsModel.Day) getChild(groupPosition, childPosition); + + final StatsViewHolder holder; + if (convertView == null) { + convertView = inflater.inflate(R.layout.stats_list_cell, parent, false); + holder = new StatsViewHolder(convertView); + convertView.setTag(holder); + } else { + holder = (StatsViewHolder) convertView.getTag(); + } + + holder.setEntryText(StatsUtils.parseDate(currentDay.getDay(), StatsConstants.STATS_INPUT_DATE_FORMAT, "EEE, MMM dd")); + + // Intercept clicks at row level and eat the event. We don't want to show the ripple here. + holder.rowContent.setOnClickListener( + new View.OnClickListener() { + @Override + public void onClick(View view) { + + } + }); + holder.rowContent.setBackgroundColor(Color.TRANSPARENT); + + // totals + holder.totalsTextView.setText(FormatUtils.formatDecimal(currentDay.getCount())); + + // show the trophy indicator if the value is the maximum reached + if (currentDay.getCount() == maxReachedValue && maxReachedValue > 0) { + holder.imgMore.setVisibility(View.VISIBLE); + holder.imgMore.setImageDrawable(getResources().getDrawable(R.drawable.stats_icon_trophy)); + holder.imgMore.setBackgroundColor(Color.TRANSPARENT); // Hide the default click indicator + } else { + holder.imgMore.setVisibility(View.GONE); + } + + holder.networkImageView.setVisibility(View.GONE); + return convertView; + } + + @Override + public int getChildrenCount(int groupPosition) { + PostViewsModel.Week week = groups.get(groupPosition); + return week.getDays().size(); + } + + @Override + public Object getGroup(int groupPosition) { + return groups.get(groupPosition); + } + + @Override + public int getGroupCount() { + return groups.size(); + } + + + @Override + public long getGroupId(int groupPosition) { + return 0; + } + + @Override + public View getGroupView(final int groupPosition, boolean isExpanded, + View convertView, ViewGroup parent) { + + final StatsViewHolder holder; + if (convertView == null) { + convertView = inflater.inflate(R.layout.stats_list_cell, parent, false); + holder = new StatsViewHolder(convertView); + convertView.setTag(holder); + } else { + holder = (StatsViewHolder) convertView.getTag(); + } + + PostViewsModel.Week week = (PostViewsModel.Week) getGroup(groupPosition); + + int total = week.getTotal(); + + // change the color of the text if one of its childs has reached maximum value + int numberOfChilds = getChildrenCount(groupPosition); + boolean shouldShowTheTrophyIcon = false; + if (maxReachedValue > 0) { + for (int i = 0; i < numberOfChilds; i++) { + PostViewsModel.Day currentChild = (PostViewsModel.Day) getChild(groupPosition, i); + if (currentChild.getCount() == maxReachedValue) { + shouldShowTheTrophyIcon = true; + } + } + } + + // Build the label to show on the group + String name; + PostViewsModel.Day firstChild = (PostViewsModel.Day) getChild(groupPosition, 0); + if (numberOfChilds > 1) { + PostViewsModel.Day lastChild = (PostViewsModel.Day) getChild(groupPosition, getChildrenCount(groupPosition) - 1); + name = StatsUtils.parseDate(firstChild.getDay(), StatsConstants.STATS_INPUT_DATE_FORMAT, GROUP_DATE_FORMAT) + + " - " + StatsUtils.parseDate(lastChild.getDay(), StatsConstants.STATS_INPUT_DATE_FORMAT, GROUP_DATE_FORMAT); + } else { + name = StatsUtils.parseDate(firstChild.getDay(), StatsConstants.STATS_INPUT_DATE_FORMAT, GROUP_DATE_FORMAT); + } + + holder.setEntryText(name, getResources().getColor(R.color.stats_link_text_color)); + + holder.networkImageView.setVisibility(View.GONE); + + // totals + holder.totalsTextView.setText(FormatUtils.formatDecimal(total)); + if (shouldShowTheTrophyIcon) { + holder.imgMore.setVisibility(View.VISIBLE); + holder.imgMore.setImageDrawable(getResources().getDrawable(R.drawable.stats_icon_trophy)); + holder.imgMore.setBackgroundColor(Color.TRANSPARENT); // Hide the default click indicator + } else { + holder.imgMore.setVisibility(View.GONE); + } + + // expand/collapse chevron + holder.chevronImageView.setVisibility(numberOfChilds > 0 ? View.VISIBLE : View.GONE); + return convertView; + } + + @Override + public boolean hasStableIds() { + return false; + } + + @Override + public boolean isChildSelectable(int groupPosition, int childPosition) { + return false; + } + + } + + + private class MonthsAndYearsListAdapter extends BaseExpandableListAdapter { + public final LayoutInflater inflater; + private final List<PostViewsModel.Year> groups; + private final int maxReachedValue; + + public MonthsAndYearsListAdapter(Context context, List<PostViewsModel.Year> groups, int maxReachedValue) { + this.groups = groups; + this.inflater = LayoutInflater.from(context); + this.maxReachedValue = maxReachedValue; + } + + @Override + public Object getChild(int groupPosition, int childPosition) { + PostViewsModel.Year currentYear = groups.get(groupPosition); + return currentYear.getMonths().get(childPosition); + } + + @Override + public long getChildId(int groupPosition, int childPosition) { + return 0; + } + + @Override + public View getChildView(int groupPosition, final int childPosition, + boolean isLastChild, View convertView, ViewGroup parent) { + + final PostViewsModel.Month currentMonth = (PostViewsModel.Month) getChild(groupPosition, childPosition); + + final StatsViewHolder holder; + if (convertView == null) { + convertView = inflater.inflate(R.layout.stats_list_cell, parent, false); + holder = new StatsViewHolder(convertView); + convertView.setTag(holder); + } else { + holder = (StatsViewHolder) convertView.getTag(); + } + + holder.setEntryText(StatsUtils.parseDate(currentMonth.getMonth(), "MM", StatsConstants.STATS_OUTPUT_DATE_MONTH_LONG_FORMAT)); + + // Intercept clicks at row level and eat the event. We don't want to show the ripple here. + holder.rowContent.setOnClickListener( + new View.OnClickListener() { + @Override + public void onClick(View view) { + + } + }); + holder.rowContent.setBackgroundColor(Color.TRANSPARENT); + + // totals + holder.totalsTextView.setText(FormatUtils.formatDecimal(currentMonth.getCount())); + + // show the trophy indicator if the value is the maximum reached + if (currentMonth.getCount() == maxReachedValue && maxReachedValue > 0) { + holder.imgMore.setVisibility(View.VISIBLE); + holder.imgMore.setImageDrawable(getResources().getDrawable(R.drawable.stats_icon_trophy)); + holder.imgMore.setBackgroundColor(Color.TRANSPARENT); // Hide the default click indicator + } else { + holder.imgMore.setVisibility(View.GONE); + } + + holder.networkImageView.setVisibility(View.GONE); + return convertView; + } + + @Override + public int getChildrenCount(int groupPosition) { + PostViewsModel.Year currentYear = groups.get(groupPosition); + return currentYear.getMonths().size(); + } + + @Override + public Object getGroup(int groupPosition) { + return groups.get(groupPosition); + } + + @Override + public int getGroupCount() { + return groups.size(); + } + + + @Override + public long getGroupId(int groupPosition) { + return 0; + } + + @Override + public View getGroupView(final int groupPosition, boolean isExpanded, + View convertView, ViewGroup parent) { + + final StatsViewHolder holder; + if (convertView == null) { + convertView = inflater.inflate(R.layout.stats_list_cell, parent, false); + holder = new StatsViewHolder(convertView); + convertView.setTag(holder); + } else { + holder = (StatsViewHolder) convertView.getTag(); + } + + PostViewsModel.Year year = (PostViewsModel.Year) getGroup(groupPosition); + + String name = year.getLabel(); + int total = year.getTotal(); + + // change the color of the text if one of its childs has reached maximum value + int numberOfChilds = getChildrenCount(groupPosition); + boolean shouldShowTheTrophyIcon = false; + if (maxReachedValue > 0) { + for (int i = 0; i < numberOfChilds; i++) { + PostViewsModel.Month currentChild = (PostViewsModel.Month) getChild(groupPosition, i); + if (currentChild.getCount() == maxReachedValue) { + shouldShowTheTrophyIcon = true; + break; + } + } + } + + holder.setEntryText(name, getResources().getColor(R.color.stats_link_text_color)); + + if (shouldShowTheTrophyIcon) { + holder.imgMore.setVisibility(View.VISIBLE); + holder.imgMore.setImageDrawable(getResources().getDrawable(R.drawable.stats_icon_trophy)); + holder.imgMore.setBackgroundColor(Color.TRANSPARENT); // Hide the default click indicator + } else { + holder.imgMore.setVisibility(View.GONE); + } + + // totals + holder.totalsTextView.setText(FormatUtils.formatDecimal(total)); + + holder.networkImageView.setVisibility(View.GONE); + + // expand/collapse chevron + holder.chevronImageView.setVisibility(numberOfChilds > 0 ? View.VISIBLE : View.GONE); + return convertView; + } + + @Override + public boolean hasStableIds() { + return false; + } + + @Override + public boolean isChildSelectable(int groupPosition, int childPosition) { + return false; + } + + } + + + private class RestBatchCallListener implements RestRequest.Listener, RestRequest.ErrorListener { + + private final WeakReference<Activity> mActivityRef; + + public RestBatchCallListener(Activity activity) { + mActivityRef = new WeakReference<>(activity); + } + + @Override + public void onResponse(final JSONObject response) { + if (mActivityRef.get() == null || mActivityRef.get().isFinishing()) { + return; + } + mIsUpdatingStats = false; + mSwipeToRefreshHelper.setRefreshing(false); + // single background thread used to parse the response in BG. + ThreadPoolExecutor parseResponseExecutor = (ThreadPoolExecutor) Executors.newFixedThreadPool(1); + parseResponseExecutor.submit(new Thread() { + @Override + public void run() { + //AppLog.d(AppLog.T.STATS, "The REST response: " + response.toString()); + mSelectedBarGraphIndex = -1; + try { + mRestResponseParsed = new PostViewsModel(response); + } catch (JSONException e) { + AppLog.e(AppLog.T.STATS, "Cannot parse the JSON response", e); + resetModelVariables(); + } + + // Update the UI + mHandler.post(new Runnable() { + @Override + public void run() { + updateUI(); + } + }); + } + }); + } + + @Override + public void onErrorResponse(final VolleyError volleyError) { + StatsUtils.logVolleyErrorDetails(volleyError); + if (mActivityRef.get() == null || mActivityRef.get().isFinishing()) { + return; + } + resetModelVariables(); + + String label = mActivityRef.get().getString(R.string.error_refresh_stats); + if (volleyError instanceof NoConnectionError) { + label += "\n" + mActivityRef.get().getString(R.string.no_network_message); + } + + ToastUtils.showToast(mActivityRef.get(), label, ToastUtils.Duration.LONG); + mIsUpdatingStats = false; + mSwipeToRefreshHelper.setRefreshing(false); + + // Update the UI + mHandler.post(new Runnable() { + @Override + public void run() { + updateUI(); + } + }); + } + } + + private void resetModelVariables() { + mRestResponseParsed = null; + mSelectedBarGraphIndex = -1; + mAveragesIdToExpandedMap.clear(); + mYearsIdToExpandedMap.clear(); + } + + @Override + public void onBarTapped(int tappedBar) { + mSelectedBarGraphIndex = tappedBar; + final VisitModel[] dataToShowOnGraph = getDataToShowOnGraph(); + String currentItemStatsDate = dataToShowOnGraph[mSelectedBarGraphIndex].getPeriod(); + currentItemStatsDate = StatsUtils.parseDate( + currentItemStatsDate, + StatsConstants.STATS_INPUT_DATE_FORMAT, + StatsConstants.STATS_OUTPUT_DATE_MONTH_LONG_DAY_SHORT_FORMAT + ); + setMainViewsLabel(currentItemStatsDate, dataToShowOnGraph[mSelectedBarGraphIndex].getViews()); + } + +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsTagsAndCategoriesFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsTagsAndCategoriesFragment.java new file mode 100644 index 000000000..9f838ea9d --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsTagsAndCategoriesFragment.java @@ -0,0 +1,282 @@ +package org.wordpress.android.ui.stats; + +import android.content.Context; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseExpandableListAdapter; + +import org.wordpress.android.R; +import org.wordpress.android.ui.stats.models.TagModel; +import org.wordpress.android.ui.stats.models.TagsContainerModel; +import org.wordpress.android.ui.stats.models.TagsModel; +import org.wordpress.android.ui.stats.service.StatsService; +import org.wordpress.android.util.DisplayUtils; +import org.wordpress.android.util.FormatUtils; + +import java.util.ArrayList; +import java.util.List; + +public class StatsTagsAndCategoriesFragment extends StatsAbstractListFragment { + public static final String TAG = StatsTagsAndCategoriesFragment.class.getSimpleName(); + + private TagsContainerModel mTagsContainer; + + @Override + protected boolean hasDataAvailable() { + return mTagsContainer != null; + } + @Override + protected void saveStatsData(Bundle outState) { + if (mTagsContainer != null) { + outState.putSerializable(ARG_REST_RESPONSE, mTagsContainer); + } + } + @Override + protected void restoreStatsData(Bundle savedInstanceState) { + if (savedInstanceState.containsKey(ARG_REST_RESPONSE)) { + mTagsContainer = (TagsContainerModel) savedInstanceState.getSerializable(ARG_REST_RESPONSE); + } + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.TagsUpdated event) { + if (!shouldUpdateFragmentOnUpdateEvent(event)) { + return; + } + + mTagsContainer = event.mTagsContainer; + mGroupIdToExpandedMap.clear(); + updateUI(); + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.SectionUpdateError event) { + if (!shouldUpdateFragmentOnErrorEvent(event)) { + return; + } + + mTagsContainer = null; + mGroupIdToExpandedMap.clear(); + showErrorUI(event.mError); + } + + @Override + protected void updateUI() { + if (!isAdded()) { + return; + } + + if (hasTags()) { + BaseExpandableListAdapter adapter = new MyExpandableListAdapter(getActivity(), getTags()); + StatsUIHelper.reloadGroupViews(getActivity(), adapter, mGroupIdToExpandedMap, mList, getMaxNumberOfItemsToShowInList()); + showHideNoResultsUI(false); + } else { + showHideNoResultsUI(true); + } + } + + private boolean hasTags() { + return mTagsContainer != null + && mTagsContainer.getTags() != null + && mTagsContainer.getTags().size() > 0; + } + + private List<TagsModel> getTags() { + if (!hasTags()) { + return new ArrayList<TagsModel>(0); + } + return mTagsContainer.getTags(); + } + + @Override + protected boolean isViewAllOptionAvailable() { + return hasTags() && getTags().size() > MAX_NUM_OF_ITEMS_DISPLAYED_IN_LIST; + } + + @Override + protected boolean isExpandableList() { + return true; + } + + @Override + protected StatsService.StatsEndpointsEnum[] sectionsToUpdate() { + return new StatsService.StatsEndpointsEnum[]{ + StatsService.StatsEndpointsEnum.TAGS_AND_CATEGORIES + }; + } + + @Override + protected int getEntryLabelResId() { + return R.string.stats_entry_tags_and_categories; + } + @Override + protected int getTotalsLabelResId() { + return R.string.stats_totals_views; + } + @Override + protected int getEmptyLabelTitleResId() { + return R.string.stats_empty_tags_and_categories; + } + @Override + protected int getEmptyLabelDescResId() { + return R.string.stats_empty_tags_and_categories_desc; + } + + private class MyExpandableListAdapter extends BaseExpandableListAdapter { + public final LayoutInflater inflater; + private final List<TagsModel> groups; + + public MyExpandableListAdapter(Context context, List<TagsModel> groups) { + this.groups = groups; + this.inflater = LayoutInflater.from(context); + } + + @Override + public Object getChild(int groupPosition, int childPosition) { + TagsModel currentGroup = groups.get(groupPosition); + List<TagModel> results = currentGroup.getTags(); + return results.get(childPosition); + } + + @Override + public long getChildId(int groupPosition, int childPosition) { + return 0; + } + + @Override + public View getChildView(int groupPosition, final int childPosition, + boolean isLastChild, View convertView, ViewGroup parent) { + + final TagModel children = (TagModel) getChild(groupPosition, childPosition); + + if (convertView == null) { + convertView = inflater.inflate(R.layout.stats_list_cell, parent, false); + // configure view holder + StatsViewHolder viewHolder = new StatsViewHolder(convertView); + + //Make the picture smaller (same size of the chevron) only for tag + ViewGroup.LayoutParams params = viewHolder.networkImageView.getLayoutParams(); + params.width = DisplayUtils.dpToPx(convertView.getContext(), 12); + params.height = params.width; + viewHolder.networkImageView.setLayoutParams(params); + + convertView.setTag(viewHolder); + } + + final StatsViewHolder holder = (StatsViewHolder) convertView.getTag(); + + // name, url + holder.setEntryTextOrLink(children.getLink(), children.getName()); + + // totals + holder.totalsTextView.setText(""); + + // icon. + holder.networkImageView.setVisibility(View.VISIBLE); + holder.networkImageView.setImageDrawable(getResources().getDrawable(R.drawable.stats_icon_tags)); + + return convertView; + } + + @Override + public int getChildrenCount(int groupPosition) { + TagsModel currentGroup = groups.get(groupPosition); + List<TagModel> tags = currentGroup.getTags(); + if (tags == null || tags.size() == 1 ) { + return 0; + } else { + return tags.size(); + } + } + + @Override + public Object getGroup(int groupPosition) { + return groups.get(groupPosition); + } + + @Override + public int getGroupCount() { + return groups.size(); + } + + + @Override + public long getGroupId(int groupPosition) { + return 0; + } + + @Override + public View getGroupView(int groupPosition, boolean isExpanded, + View convertView, ViewGroup parent) { + + if (convertView == null) { + convertView = inflater.inflate(R.layout.stats_list_cell, parent, false); + // configure view holder + StatsViewHolder viewHolder = new StatsViewHolder(convertView); + convertView.setTag(viewHolder); + + //Make the picture smaller (same size of the chevron) only for tag + ViewGroup.LayoutParams params = viewHolder.networkImageView.getLayoutParams(); + params.width = DisplayUtils.dpToPx(convertView.getContext(), 12); + params.height = params.width; + viewHolder.networkImageView.setLayoutParams(params); + } + + final StatsViewHolder holder = (StatsViewHolder) convertView.getTag(); + + TagsModel group = (TagsModel) getGroup(groupPosition); + StringBuilder groupName = new StringBuilder(); + List<TagModel> tags = group.getTags(); + for (int i = 0; i < tags.size(); i++) { + TagModel currentTag = tags.get(i); + groupName.append(currentTag.getName()); + if ( i < (tags.size() - 1)) { + groupName.append(" | "); + } + } + int total = group.getViews(); + int children = getChildrenCount(groupPosition); + + if (children > 0) { + holder.setEntryText(groupName.toString(), getResources().getColor(R.color.stats_link_text_color)); + } else { + holder.setEntryTextOrLink(tags.get(0).getLink(), groupName.toString()); + } + + // totals + holder.totalsTextView.setText(FormatUtils.formatDecimal(total)); + + // expand/collapse chevron + holder.chevronImageView.setVisibility(children > 0 ? View.VISIBLE : View.GONE); + + + // icon + if ( children == 0 ) { + holder.networkImageView.setVisibility(View.VISIBLE); + int drawableResource = groupName.toString().equalsIgnoreCase("uncategorized") ? R.drawable.stats_icon_categories + : R.drawable.stats_icon_tags; + holder.networkImageView.setImageDrawable(getResources().getDrawable(drawableResource)); + } + + return convertView; + } + + @Override + public boolean hasStableIds() { + return false; + } + + @Override + public boolean isChildSelectable(int groupPosition, int childPosition) { + return false; + } + + } + + @Override + public String getTitle() { + return getString(R.string.stats_view_tags_and_categories); + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsTimeframe.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsTimeframe.java new file mode 100644 index 000000000..e9cae5b63 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsTimeframe.java @@ -0,0 +1,39 @@ +package org.wordpress.android.ui.stats; + +import org.wordpress.android.R; +import org.wordpress.android.WordPress; + +/** + * Timeframes for the stats pages. + */ +public enum StatsTimeframe { + INSIGHTS(R.string.stats_insights), + DAY(R.string.stats_timeframe_days), + WEEK(R.string.stats_timeframe_weeks), + MONTH(R.string.stats_timeframe_months), + YEAR(R.string.stats_timeframe_years), + ; + + private final int mLabelResId; + + StatsTimeframe(int labelResId) { + mLabelResId = labelResId; + } + + public String getLabel() { + return WordPress.getContext().getString(mLabelResId); + } + + public String getLabelForRestCall() { + switch (this) { + case WEEK: + return "week"; + case MONTH: + return "month"; + case YEAR: + return "year"; + default: + return "day"; + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsTopPostsAndPagesFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsTopPostsAndPagesFragment.java new file mode 100644 index 000000000..32a3742fa --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsTopPostsAndPagesFragment.java @@ -0,0 +1,129 @@ +package org.wordpress.android.ui.stats; + +import android.os.Bundle; +import android.widget.ArrayAdapter; + +import org.wordpress.android.R; +import org.wordpress.android.ui.stats.adapters.PostsAndPagesAdapter; +import org.wordpress.android.ui.stats.models.PostModel; +import org.wordpress.android.ui.stats.models.TopPostsAndPagesModel; +import org.wordpress.android.ui.stats.service.StatsService; + +import java.util.ArrayList; +import java.util.List; + + +public class StatsTopPostsAndPagesFragment extends StatsAbstractListFragment { + public static final String TAG = StatsTopPostsAndPagesFragment.class.getSimpleName(); + + private TopPostsAndPagesModel mTopPostsAndPagesModel = null; + + @Override + protected boolean hasDataAvailable() { + return mTopPostsAndPagesModel != null; + } + @Override + protected void saveStatsData(Bundle outState) { + if (hasDataAvailable()) { + outState.putSerializable(ARG_REST_RESPONSE, mTopPostsAndPagesModel); + } + } + @Override + protected void restoreStatsData(Bundle savedInstanceState) { + if (savedInstanceState.containsKey(ARG_REST_RESPONSE)) { + mTopPostsAndPagesModel = (TopPostsAndPagesModel) savedInstanceState.getSerializable(ARG_REST_RESPONSE); + } + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.TopPostsUpdated event) { + if (!shouldUpdateFragmentOnUpdateEvent(event)) { + return; + } + + mGroupIdToExpandedMap.clear(); + mTopPostsAndPagesModel = event.mTopPostsAndPagesModel; + + updateUI(); + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.SectionUpdateError event) { + if (!shouldUpdateFragmentOnErrorEvent(event)) { + return; + } + + mTopPostsAndPagesModel = null; + mGroupIdToExpandedMap.clear(); + showErrorUI(event.mError); + } + + @Override + protected void updateUI() { + if (!isAdded()) { + return; + } + + if (hasTopPostsAndPages()) { + List<PostModel> postViews = mTopPostsAndPagesModel.getTopPostsAndPages(); + ArrayAdapter adapter = new PostsAndPagesAdapter(getActivity(), postViews); + StatsUIHelper.reloadLinearLayout(getActivity(), adapter, mList, getMaxNumberOfItemsToShowInList()); + showHideNoResultsUI(false); + } else { + showHideNoResultsUI(true); + } + } + + private boolean hasTopPostsAndPages() { + return mTopPostsAndPagesModel != null && mTopPostsAndPagesModel.hasTopPostsAndPages(); + } + + private List<PostModel> getTopPostsAndPages() { + if (!hasTopPostsAndPages()) { + return new ArrayList<PostModel>(0); + } + return mTopPostsAndPagesModel.getTopPostsAndPages(); + } + + @Override + protected boolean isViewAllOptionAvailable() { + return hasTopPostsAndPages() && getTopPostsAndPages().size() > MAX_NUM_OF_ITEMS_DISPLAYED_IN_LIST; + } + + @Override + protected boolean isExpandableList() { + return false; + } + + @Override + protected int getEntryLabelResId() { + return R.string.stats_entry_posts_and_pages; + } + + @Override + protected int getTotalsLabelResId() { + return R.string.stats_totals_views; + } + + @Override + protected int getEmptyLabelTitleResId() { + return R.string.stats_empty_top_posts_title; + } + + @Override + protected int getEmptyLabelDescResId() { + return R.string.stats_empty_top_posts_desc; + } + + @Override + protected StatsService.StatsEndpointsEnum[] sectionsToUpdate() { + return new StatsService.StatsEndpointsEnum[]{ + StatsService.StatsEndpointsEnum.TOP_POSTS + }; + } + + @Override + public String getTitle() { + return getString(R.string.stats_view_top_posts_and_pages); + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsUIHelper.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsUIHelper.java new file mode 100644 index 000000000..441ed3050 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsUIHelper.java @@ -0,0 +1,344 @@ +package org.wordpress.android.ui.stats; + +import android.app.Activity; +import android.content.Context; +import android.graphics.Color; +import android.graphics.Point; +import android.text.Spannable; +import android.text.style.URLSpan; +import android.util.SparseBooleanArray; +import android.view.Display; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.AccelerateInterpolator; +import android.view.animation.Animation; +import android.view.animation.Interpolator; +import android.view.animation.RotateAnimation; +import android.view.animation.ScaleAnimation; +import android.widget.ExpandableListAdapter; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.ListAdapter; + +import org.wordpress.android.R; +import org.wordpress.android.WordPress; +import org.wordpress.android.util.DisplayUtils; + +class StatsUIHelper { + // Max number of rows to show in a stats fragment + private static final int STATS_GROUP_MAX_ITEMS = 10; + private static final int STATS_CHILD_MAX_ITEMS = 50; + private static final int ANIM_DURATION = 150; + + // Used for tablet UI + private static final int TABLET_720DP = 720; + private static final int TABLET_600DP = 600; + + private static boolean isInLandscape(Activity act) { + Display display = act.getWindowManager().getDefaultDisplay(); + Point point = new Point(); + display.getSize(point); + return (point.y < point.x); + } + + // Load more bars for 720DP tablets + private static boolean shouldLoadMoreBars() { + return (StatsUtils.getSmallestWidthDP() >= TABLET_720DP); + } + + public static void reloadLinearLayout(Context ctx, ListAdapter adapter, LinearLayout linearLayout, int maxNumberOfItemsToshow) { + if (ctx == null || linearLayout == null || adapter == null) { + return; + } + + // limit number of items to show otherwise it would cause performance issues on the LinearLayout + int count = Math.min(adapter.getCount(), maxNumberOfItemsToshow); + + if (count == 0) { + linearLayout.removeAllViews(); + return; + } + + int numExistingViews = linearLayout.getChildCount(); + // remove excess views + if (count < numExistingViews) { + int numToRemove = numExistingViews - count; + linearLayout.removeViews(count, numToRemove); + numExistingViews = count; + } + + int bgColor = Color.TRANSPARENT; + for (int i = 0; i < count; i++) { + final View view; + // reuse existing view when possible + if (i < numExistingViews) { + View convertView = linearLayout.getChildAt(i); + view = adapter.getView(i, convertView, linearLayout); + view.setBackgroundColor(bgColor); + setViewBackgroundWithoutResettingPadding(view, i == 0 ? 0 : R.drawable.stats_list_item_background); + } else { + view = adapter.getView(i, null, linearLayout); + view.setBackgroundColor(bgColor); + setViewBackgroundWithoutResettingPadding(view, i == 0 ? 0 : R.drawable.stats_list_item_background); + linearLayout.addView(view); + } + } + linearLayout.invalidate(); + } + + /** + * + * Padding information are reset when changing the background Drawable on a View. + * The reason why setting an image resets the padding is because 9-patch images can encode padding. + * + * See http://stackoverflow.com/a/10469121 and + * http://www.mail-archive.com/android-developers@googlegroups.com/msg09595.html + * + * @param v The view to apply the background resource + * @param backgroundResId The resource ID + */ + private static void setViewBackgroundWithoutResettingPadding(final View v, final int backgroundResId) { + final int paddingBottom = v.getPaddingBottom(), paddingLeft = v.getPaddingLeft(); + final int paddingRight = v.getPaddingRight(), paddingTop = v.getPaddingTop(); + v.setBackgroundResource(backgroundResId); + v.setPadding(paddingLeft, paddingTop, paddingRight, paddingBottom); + } + + public static void reloadLinearLayout(Context ctx, ListAdapter adapter, LinearLayout linearLayout) { + reloadLinearLayout(ctx, adapter, linearLayout, STATS_GROUP_MAX_ITEMS); + } + + public static void reloadGroupViews(final Context ctx, + final ExpandableListAdapter mAdapter, + final SparseBooleanArray mGroupIdToExpandedMap, + final LinearLayout mLinearLayout) { + reloadGroupViews(ctx, mAdapter, mGroupIdToExpandedMap, mLinearLayout, STATS_GROUP_MAX_ITEMS); + } + + public static void reloadGroupViews(final Context ctx, + final ExpandableListAdapter mAdapter, + final SparseBooleanArray mGroupIdToExpandedMap, + final LinearLayout mLinearLayout, + final int maxNumberOfItemsToshow) { + if (ctx == null || mLinearLayout == null || mAdapter == null || mGroupIdToExpandedMap == null) { + return; + } + + int groupCount = Math.min(mAdapter.getGroupCount(), maxNumberOfItemsToshow); + if (groupCount == 0) { + mLinearLayout.removeAllViews(); + return; + } + + int numExistingGroupViews = mLinearLayout.getChildCount(); + + // remove excess views + if (groupCount < numExistingGroupViews) { + int numToRemove = numExistingGroupViews - groupCount; + mLinearLayout.removeViews(groupCount, numToRemove); + numExistingGroupViews = groupCount; + } + + int bgColor = Color.TRANSPARENT; + + // add each group + for (int i = 0; i < groupCount; i++) { + boolean isExpanded = mGroupIdToExpandedMap.get(i); + + // reuse existing view when possible + final View groupView; + if (i < numExistingGroupViews) { + View convertView = mLinearLayout.getChildAt(i); + groupView = mAdapter.getGroupView(i, isExpanded, convertView, mLinearLayout); + groupView.setBackgroundColor(bgColor); + setViewBackgroundWithoutResettingPadding(groupView, i == 0 ? 0 : R.drawable.stats_list_item_background); + } else { + groupView = mAdapter.getGroupView(i, isExpanded, null, mLinearLayout); + groupView.setBackgroundColor(bgColor); + setViewBackgroundWithoutResettingPadding(groupView, i == 0 ? 0 : R.drawable.stats_list_item_background); + mLinearLayout.addView(groupView); + } + + // groupView is recycled, we need to reset it to the original state. + ViewGroup childContainer = (ViewGroup) groupView.findViewById(R.id.layout_child_container); + if (childContainer != null) { + childContainer.setVisibility(View.GONE); + } + // Remove any other prev animations set on the chevron + final ImageView chevron = (ImageView) groupView.findViewById(R.id.stats_list_cell_chevron); + if (chevron != null) { + chevron.clearAnimation(); + chevron.setImageResource(R.drawable.stats_chevron_right); + } + + // add children if this group is expanded + if (isExpanded) { + StatsUIHelper.showChildViews(mAdapter, mLinearLayout, i, groupView, false); + } + + // toggle expand/collapse when group view is tapped + final int groupPosition = i; + groupView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (mAdapter.getChildrenCount(groupPosition) == 0) { + return; + } + boolean shouldExpand = !mGroupIdToExpandedMap.get(groupPosition); + mGroupIdToExpandedMap.put(groupPosition, shouldExpand); + if (shouldExpand) { + StatsUIHelper.showChildViews(mAdapter, mLinearLayout, groupPosition, groupView, true); + } else { + StatsUIHelper.hideChildViews(groupView, groupPosition, true); + } + } + }); + } + } + + /* + * interpolator for all expand/collapse animations + */ + private static Interpolator getInterpolator() { + return new AccelerateInterpolator(); + } + + private static void hideChildViews(View groupView, int groupPosition, boolean animate) { + final ViewGroup childContainer = (ViewGroup) groupView.findViewById(R.id.layout_child_container); + if (childContainer == null) { + return; + } + + if (childContainer.getVisibility() != View.GONE) { + if (animate) { + Animation expand = new ScaleAnimation(1.0f, 1.0f, 1.0f, 0.0f); + expand.setDuration(ANIM_DURATION); + expand.setInterpolator(getInterpolator()); + expand.setAnimationListener(new Animation.AnimationListener() { + @Override + public void onAnimationStart(Animation animation) { } + @Override + public void onAnimationEnd(Animation animation) { + childContainer.setVisibility(View.GONE); + } + @Override + public void onAnimationRepeat(Animation animation) { } + }); + childContainer.startAnimation(expand); + } else { + childContainer.setVisibility(View.GONE); + } + } + StatsUIHelper.setGroupChevron(false, groupView, groupPosition, animate); + } + + /* + * shows the correct up/down chevron for the passed group + */ + private static void setGroupChevron(final boolean isGroupExpanded, View groupView, int groupPosition, boolean animate) { + final ImageView chevron = (ImageView) groupView.findViewById(R.id.stats_list_cell_chevron); + if (chevron == null) { + return; + } + if (isGroupExpanded) { + // change the background of the parent + setViewBackgroundWithoutResettingPadding(groupView, R.drawable.stats_list_item_expanded_background); + } else { + setViewBackgroundWithoutResettingPadding(groupView, groupPosition == 0 ? 0 : R.drawable.stats_list_item_background); + } + + chevron.clearAnimation(); // Remove any other prev animations set on the chevron + if (animate) { + // make sure we start with the correct chevron for the prior state before animating it + chevron.setImageResource(isGroupExpanded ? R.drawable.stats_chevron_right : R.drawable.stats_chevron_down); + float start = (isGroupExpanded ? 0.0f : 0.0f); + float end = (isGroupExpanded ? 90.0f : -90.0f); + Animation rotate = new RotateAnimation(start, end, Animation.RELATIVE_TO_SELF, 0.5f, + Animation.RELATIVE_TO_SELF, 0.5f); + rotate.setDuration(ANIM_DURATION); + rotate.setInterpolator(getInterpolator()); + rotate.setFillAfter(true); + chevron.startAnimation(rotate); + } else { + chevron.setImageResource(isGroupExpanded ? R.drawable.stats_chevron_down : R.drawable.stats_chevron_right); + } + } + + private static void showChildViews(ExpandableListAdapter mAdapter, LinearLayout mLinearLayout, + int groupPosition, View groupView, boolean animate) { + int childCount = Math.min(mAdapter.getChildrenCount(groupPosition), STATS_CHILD_MAX_ITEMS); + if (childCount == 0) { + return; + } + + final ViewGroup childContainer = (ViewGroup) groupView.findViewById(R.id.layout_child_container); + if (childContainer == null) { + return; + } + + int numExistingViews = childContainer.getChildCount(); + if (childCount < numExistingViews) { + int numToRemove = numExistingViews - childCount; + childContainer.removeViews(childCount, numToRemove); + numExistingViews = childCount; + } + + for (int i = 0; i < childCount; i++) { + boolean isLastChild = (i == childCount - 1); + if (i < numExistingViews) { + View convertView = childContainer.getChildAt(i); + mAdapter.getChildView(groupPosition, i, isLastChild, convertView, mLinearLayout); + } else { + View childView = mAdapter.getChildView(groupPosition, i, isLastChild, null, mLinearLayout); + // remove the right/left padding so the child total aligns to left + childView.setPadding(0, + childView.getPaddingTop(), + 0, + isLastChild ? 0 : childView.getPaddingBottom()); // No padding bottom on last child + setViewBackgroundWithoutResettingPadding(childView, R.drawable.stats_list_item_child_background); + childContainer.addView(childView); + } + } + + if (childContainer.getVisibility() != View.VISIBLE) { + if (animate) { + Animation expand = new ScaleAnimation(1.0f, 1.0f, 0.0f, 1.0f); + expand.setDuration(ANIM_DURATION); + expand.setInterpolator(getInterpolator()); + childContainer.startAnimation(expand); + } + childContainer.setVisibility(View.VISIBLE); + } + + StatsUIHelper.setGroupChevron(true, groupView, groupPosition, animate); + } + + /** + * Removes URL underlines in a string by replacing URLSpan occurrences by + * URLSpanNoUnderline objects. + * + * @param pText A Spannable object. For example, a TextView casted as + * Spannable. + */ + public static void removeUnderlines(Spannable pText) { + URLSpan[] spans = pText.getSpans(0, pText.length(), URLSpan.class); + + for(URLSpan span:spans) { + int start = pText.getSpanStart(span); + int end = pText.getSpanEnd(span); + pText.removeSpan(span); + span = new URLSpanNoUnderline(span.getURL()); + pText.setSpan(span, start, end, 0); + } + } + + public static int getNumOfBarsToShow() { + if (StatsUtils.getSmallestWidthDP() >= TABLET_720DP && DisplayUtils.isLandscape(WordPress.getContext())) { + return 15; + } else if (StatsUtils.getSmallestWidthDP() >= TABLET_600DP) { + return 10; + } else { + return 7; + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsUtils.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsUtils.java new file mode 100644 index 000000000..15467db20 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsUtils.java @@ -0,0 +1,558 @@ +package org.wordpress.android.ui.stats; + +import android.annotation.SuppressLint; +import android.content.Context; + +import com.android.volley.NetworkResponse; +import com.android.volley.VolleyError; + +import org.json.JSONException; +import org.json.JSONObject; +import org.wordpress.android.R; +import org.wordpress.android.WordPress; +import org.wordpress.android.models.Blog; +import org.wordpress.android.ui.WPWebViewActivity; +import org.wordpress.android.ui.reader.ReaderActivityLauncher; +import org.wordpress.android.ui.stats.exceptions.StatsError; +import org.wordpress.android.ui.stats.models.AuthorsModel; +import org.wordpress.android.ui.stats.models.BaseStatsModel; +import org.wordpress.android.ui.stats.models.ClicksModel; +import org.wordpress.android.ui.stats.models.CommentFollowersModel; +import org.wordpress.android.ui.stats.models.CommentsModel; +import org.wordpress.android.ui.stats.models.FollowersModel; +import org.wordpress.android.ui.stats.models.GeoviewsModel; +import org.wordpress.android.ui.stats.models.InsightsAllTimeModel; +import org.wordpress.android.ui.stats.models.InsightsLatestPostDetailsModel; +import org.wordpress.android.ui.stats.models.InsightsLatestPostModel; +import org.wordpress.android.ui.stats.models.InsightsPopularModel; +import org.wordpress.android.ui.stats.models.InsightsTodayModel; +import org.wordpress.android.ui.stats.models.PostModel; +import org.wordpress.android.ui.stats.models.PublicizeModel; +import org.wordpress.android.ui.stats.models.ReferrersModel; +import org.wordpress.android.ui.stats.models.SearchTermsModel; +import org.wordpress.android.ui.stats.models.TagsContainerModel; +import org.wordpress.android.ui.stats.models.TopPostsAndPagesModel; +import org.wordpress.android.ui.stats.models.VideoPlaysModel; +import org.wordpress.android.ui.stats.models.VisitsModel; +import org.wordpress.android.ui.stats.service.StatsService; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; + +import java.io.Serializable; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; +import java.util.TimeZone; +import java.util.concurrent.TimeUnit; + +public class StatsUtils { + @SuppressLint("SimpleDateFormat") + private static long toMs(String date, String pattern) { + if (date == null || date.equals("null")) { + AppLog.w(T.UTILS, "Trying to parse a 'null' Stats Date."); + return -1; + } + + if (pattern == null) { + AppLog.w(T.UTILS, "Trying to parse a Stats date with a null pattern"); + return -1; + } + + SimpleDateFormat sdf = new SimpleDateFormat(pattern); + try { + return sdf.parse(date).getTime(); + } catch (ParseException e) { + AppLog.e(T.UTILS, e); + } + return -1; + } + + /** + * Converts date in the form of 2013-07-18 to ms * + */ + public static long toMs(String date) { + return toMs(date, StatsConstants.STATS_INPUT_DATE_FORMAT); + } + + public static String msToString(long ms, String format) { + SimpleDateFormat sdf = new SimpleDateFormat(format); + return sdf.format(new Date(ms)); + } + + /** + * Get the current date of the blog in the form of yyyy-MM-dd (EX: 2013-07-18) * + */ + public static String getCurrentDateTZ(int localTableBlogID) { + String timezone = StatsUtils.getBlogTimezone(WordPress.getBlog(localTableBlogID)); + if (timezone == null) { + AppLog.w(T.UTILS, "Timezone is null. Returning the device time!!"); + return getCurrentDate(); + } + + return getCurrentDateTimeTZ(timezone, StatsConstants.STATS_INPUT_DATE_FORMAT); + } + + /** + * Get the current datetime of the blog * + */ + public static String getCurrentDateTimeTZ(int localTableBlogID) { + String timezone = StatsUtils.getBlogTimezone(WordPress.getBlog(localTableBlogID)); + if (timezone == null) { + AppLog.w(T.UTILS, "Timezone is null. Returning the device time!!"); + return getCurrentDatetime(); + } + String pattern = "yyyy-MM-dd HH:mm:ss"; // precision to seconds + return getCurrentDateTimeTZ(timezone, pattern); + } + + /** + * Get the current datetime of the blog in Ms * + */ + public static long getCurrentDateTimeMsTZ(int localTableBlogID) { + String timezone = StatsUtils.getBlogTimezone(WordPress.getBlog(localTableBlogID)); + if (timezone == null) { + AppLog.w(T.UTILS, "Timezone is null. Returning the device time!!"); + return new Date().getTime(); + } + String pattern = "yyyy-MM-dd HH:mm:ss"; // precision to seconds + return toMs(getCurrentDateTimeTZ(timezone, pattern), pattern); + } + + /** + * Get the current date in the form of yyyy-MM-dd (EX: 2013-07-18) * + */ + public static String getCurrentDate() { + SimpleDateFormat sdf = new SimpleDateFormat(StatsConstants.STATS_INPUT_DATE_FORMAT); + return sdf.format(new Date()); + } + + /** + * Get the current date in the form of "yyyy-MM-dd HH:mm:ss" + */ + private static String getCurrentDatetime() { + String pattern = "yyyy-MM-dd HH:mm:ss"; // precision to seconds + SimpleDateFormat sdf = new SimpleDateFormat(pattern); + return sdf.format(new Date()); + } + + private static String getBlogTimezone(Blog blog) { + if (blog == null) { + AppLog.w(T.UTILS, "Blog object is null!! Can't read timezone opt then."); + return null; + } + + JSONObject jsonOptions = blog.getBlogOptionsJSONObject(); + String timezone = null; + if (jsonOptions != null && jsonOptions.has("time_zone")) { + try { + timezone = jsonOptions.getJSONObject("time_zone").getString("value"); + } catch (JSONException e) { + AppLog.e(T.UTILS, "Cannot load time_zone from options: " + jsonOptions, e); + } + } else { + AppLog.w(T.UTILS, "Blog options are null, or doesn't contain time_zone"); + } + return timezone; + } + + private static String getCurrentDateTimeTZ(String blogTimeZoneOption, String pattern) { + Date date = new Date(); + SimpleDateFormat gmtDf = new SimpleDateFormat(pattern); + + if (blogTimeZoneOption == null) { + AppLog.w(T.UTILS, "blogTimeZoneOption is null. getCurrentDateTZ() will return the device time!"); + return gmtDf.format(date); + } + + /* + Convert the timezone to a form that is compatible with Java TimeZone class + WordPress returns something like the following: + UTC+0:30 ----> 0.5 + UTC+1 ----> 1.0 + UTC-0:30 ----> -1.0 + */ + + AppLog.v(T.STATS, "Parsing the following Timezone received from WP: " + blogTimeZoneOption); + String timezoneNormalized; + if (blogTimeZoneOption.equals("0") || blogTimeZoneOption.equals("0.0")) { + timezoneNormalized = "GMT"; + } else { + String[] timezoneSplitted = org.apache.commons.lang.StringUtils.split(blogTimeZoneOption, "."); + timezoneNormalized = timezoneSplitted[0]; + if(timezoneSplitted.length > 1 && timezoneSplitted[1].equals("5")){ + timezoneNormalized += ":30"; + } + if (timezoneNormalized.startsWith("-")) { + timezoneNormalized = "GMT" + timezoneNormalized; + } else { + if (timezoneNormalized.startsWith("+")) { + timezoneNormalized = "GMT" + timezoneNormalized; + } else { + timezoneNormalized = "GMT+" + timezoneNormalized; + } + } + } + + AppLog.v(T.STATS, "Setting the following Timezone: " + timezoneNormalized); + gmtDf.setTimeZone(TimeZone.getTimeZone(timezoneNormalized)); + return gmtDf.format(date); + } + + public static String parseDate(String timestamp, String fromFormat, String toFormat) { + SimpleDateFormat from = new SimpleDateFormat(fromFormat); + SimpleDateFormat to = new SimpleDateFormat(toFormat); + try { + Date date = from.parse(timestamp); + return to.format(date); + } catch (ParseException e) { + AppLog.e(T.STATS, e); + } + return ""; + } + + /** + * Get a diff between two dates + * @param date1 the oldest date in Ms + * @param date2 the newest date in Ms + * @param timeUnit the unit in which you want the diff + * @return the diff value, in the provided unit + */ + public static long getDateDiff(Date date1, Date date2, TimeUnit timeUnit) { + long diffInMillies = date2.getTime() - date1.getTime(); + return timeUnit.convert(diffInMillies, TimeUnit.MILLISECONDS); + } + + + //Calculate the correct start/end date for the selected period + public static String getPublishedEndpointPeriodDateParameters(StatsTimeframe timeframe, String date) { + if (date == null) { + AppLog.w(AppLog.T.STATS, "Can't calculate start and end period without a reference date"); + return null; + } + + try { + SimpleDateFormat sdf = new SimpleDateFormat(StatsConstants.STATS_INPUT_DATE_FORMAT); + Calendar c = Calendar.getInstance(); + c.setFirstDayOfWeek(Calendar.MONDAY); + Date parsedDate = sdf.parse(date); + c.setTime(parsedDate); + + + final String after; + final String before; + switch (timeframe) { + case DAY: + after = StatsUtils.msToString(c.getTimeInMillis(), StatsConstants.STATS_INPUT_DATE_FORMAT); + c.add(Calendar.DAY_OF_YEAR, +1); + before = StatsUtils.msToString(c.getTimeInMillis(), StatsConstants.STATS_INPUT_DATE_FORMAT); + break; + case WEEK: + c.set(Calendar.DAY_OF_WEEK, Calendar.MONDAY); + after = StatsUtils.msToString(c.getTimeInMillis(), StatsConstants.STATS_INPUT_DATE_FORMAT); + c.set(Calendar.DAY_OF_WEEK, Calendar.SUNDAY); + c.add(Calendar.DAY_OF_YEAR, +1); + before = StatsUtils.msToString(c.getTimeInMillis(), StatsConstants.STATS_INPUT_DATE_FORMAT); + break; + case MONTH: + //first day of the next month + c.set(Calendar.DAY_OF_MONTH, c.getActualMaximum(Calendar.DAY_OF_MONTH)); + c.add(Calendar.DAY_OF_YEAR, +1); + before = StatsUtils.msToString(c.getTimeInMillis(), StatsConstants.STATS_INPUT_DATE_FORMAT); + + //last day of the prev month + c.setTime(parsedDate); + c.set(Calendar.DAY_OF_MONTH, c.getActualMinimum(Calendar.DAY_OF_MONTH)); + after = StatsUtils.msToString(c.getTimeInMillis(), StatsConstants.STATS_INPUT_DATE_FORMAT); + break; + case YEAR: + //first day of the next year + c.set(Calendar.MONTH, Calendar.DECEMBER); + c.set(Calendar.DAY_OF_MONTH, 31); + c.add(Calendar.DAY_OF_YEAR, +1); + before = StatsUtils.msToString(c.getTimeInMillis(), StatsConstants.STATS_INPUT_DATE_FORMAT); + + c.setTime(parsedDate); + c.set(Calendar.MONTH, Calendar.JANUARY); + c.set(Calendar.DAY_OF_MONTH, 1); + after = StatsUtils.msToString(c.getTimeInMillis(), StatsConstants.STATS_INPUT_DATE_FORMAT); + break; + default: + AppLog.w(AppLog.T.STATS, "Can't calculate start and end period without a reference timeframe"); + return null; + } + return "&after=" + after + "&before=" + before; + } catch (ParseException e) { + AppLog.e(AppLog.T.UTILS, e); + return null; + } + } + + public static int getSmallestWidthDP() { + return WordPress.getContext().getResources().getInteger(R.integer.smallest_width_dp); + } + + public static int getLocalBlogIdFromRemoteBlogId(int remoteBlogID) { + // workaround: There are 2 entries in the DB for each Jetpack blog linked with + // the current wpcom account. We need to load the correct localID here, otherwise options are + // blank + int localId = WordPress.wpDB.getLocalTableBlogIdForJetpackRemoteID( + remoteBlogID, + null); + if (localId == 0) { + localId = WordPress.wpDB.getLocalTableBlogIdForRemoteBlogId( + remoteBlogID + ); + } + + return localId; + } + + public static synchronized void logVolleyErrorDetails(final VolleyError volleyError) { + if (volleyError == null) { + AppLog.e(T.STATS, "Tried to log a VolleyError, but the error obj was null!"); + return; + } + if (volleyError.networkResponse != null) { + NetworkResponse networkResponse = volleyError.networkResponse; + AppLog.e(T.STATS, "Network status code: " + networkResponse.statusCode); + if (networkResponse.data != null) { + AppLog.e(T.STATS, "Network data: " + new String(networkResponse.data)); + } + } + AppLog.e(T.STATS, "Volley Error Message: " + volleyError.getMessage(), volleyError); + } + + public static synchronized boolean isRESTDisabledError(final Serializable error) { + if (error == null || !(error instanceof com.android.volley.AuthFailureError)) { + return false; + } + com.android.volley.AuthFailureError volleyError = (com.android.volley.AuthFailureError) error; + if (volleyError.networkResponse != null && volleyError.networkResponse.data != null) { + String errorMessage = new String(volleyError.networkResponse.data).toLowerCase(); + return errorMessage.contains("api calls") && errorMessage.contains("disabled"); + } else { + AppLog.e(T.STATS, "Network response is null in Volley. Can't check if it is a Rest Disabled error."); + return false; + } + } + + public static synchronized BaseStatsModel parseResponse(StatsService.StatsEndpointsEnum endpointName, String blogID, JSONObject response) + throws JSONException { + BaseStatsModel model = null; + switch (endpointName) { + case VISITS: + model = new VisitsModel(blogID, response); + break; + case TOP_POSTS: + model = new TopPostsAndPagesModel(blogID, response); + break; + case REFERRERS: + model = new ReferrersModel(blogID, response); + break; + case CLICKS: + model = new ClicksModel(blogID, response); + break; + case GEO_VIEWS: + model = new GeoviewsModel(blogID, response); + break; + case AUTHORS: + model = new AuthorsModel(blogID, response); + break; + case VIDEO_PLAYS: + model = new VideoPlaysModel(blogID, response); + break; + case COMMENTS: + model = new CommentsModel(blogID, response); + break; + case FOLLOWERS_WPCOM: + model = new FollowersModel(blogID, response); + break; + case FOLLOWERS_EMAIL: + model = new FollowersModel(blogID, response); + break; + case COMMENT_FOLLOWERS: + model = new CommentFollowersModel(blogID, response); + break; + case TAGS_AND_CATEGORIES: + model = new TagsContainerModel(blogID, response); + break; + case PUBLICIZE: + model = new PublicizeModel(blogID, response); + break; + case SEARCH_TERMS: + model = new SearchTermsModel(blogID, response); + break; + case INSIGHTS_ALL_TIME: + model = new InsightsAllTimeModel(blogID, response); + break; + case INSIGHTS_POPULAR: + model = new InsightsPopularModel(blogID, response); + break; + case INSIGHTS_TODAY: + model = new InsightsTodayModel(blogID, response); + break; + case INSIGHTS_LATEST_POST_SUMMARY: + model = new InsightsLatestPostModel(blogID, response); + break; + case INSIGHTS_LATEST_POST_VIEWS: + model = new InsightsLatestPostDetailsModel(blogID, response); + break; + } + return model; + } + + public static void openPostInReaderOrInAppWebview(Context ctx, final String remoteBlogID, + final String remoteItemID, + final String itemType, + final String itemURL) { + final long blogID = Long.parseLong(remoteBlogID); + final long itemID = Long.parseLong(remoteItemID); + if (itemType == null) { + // If we don't know the type of the item, open it with the browser. + AppLog.d(AppLog.T.UTILS, "Type of the item is null. Opening it in the in-app browser: " + itemURL); + WPWebViewActivity.openURL(ctx, itemURL); + } else if (itemType.equals(StatsConstants.ITEM_TYPE_POST) + || itemType.equals(StatsConstants.ITEM_TYPE_PAGE)) { + // If the post/page has ID == 0 is the home page, and we need to load the blog preview, + // otherwise 404 is returned if we try to show the post in the reader + if (itemID == 0) { + ReaderActivityLauncher.showReaderBlogPreview( + ctx, + blogID + ); + } else { + ReaderActivityLauncher.showReaderPostDetail( + ctx, + blogID, + itemID + ); + } + } else if (itemType.equals(StatsConstants.ITEM_TYPE_HOME_PAGE)) { + ReaderActivityLauncher.showReaderBlogPreview( + ctx, + blogID + ); + } else { + AppLog.d(AppLog.T.UTILS, "Opening the in-app browser: " + itemURL); + WPWebViewActivity.openURL(ctx, itemURL); + } + } + + public static void openPostInReaderOrInAppWebview(Context ctx, final PostModel post) { + final String postType = post.getPostType(); + final String url = post.getUrl(); + final String blogID = post.getBlogID(); + final String itemID = post.getItemID(); + openPostInReaderOrInAppWebview(ctx, blogID, itemID, postType, url); + } + + /* + * This function rewrites a VolleyError into a simple Stats Error by getting the error message. + * This is a FIX for https://github.com/wordpress-mobile/WordPress-Android/issues/2228 where + * VolleyErrors cannot be serializable. + */ + public static StatsError rewriteVolleyError(VolleyError volleyError, String defaultErrorString) { + if (volleyError != null && volleyError.getMessage() != null) { + return new StatsError(volleyError.getMessage()); + } + + if (defaultErrorString != null) { + return new StatsError(defaultErrorString); + } + + // Error string should be localized here, but don't want to pass a context + return new StatsError("Stats couldn't be refreshed at this time"); + } + + + private static int roundUp(double num, double divisor) { + double unrounded = num / divisor; + //return (int) Math.ceil(unrounded); + return (int) (unrounded + 0.5); + } + + public static String getSinceLabel(Context ctx, String dataSubscribed) { + + Date currentDateTime = new Date(); + + try { + SimpleDateFormat from = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ"); + Date date = from.parse(dataSubscribed); + + // See http://momentjs.com/docs/#/displaying/fromnow/ + long currentDifference = Math.abs( + StatsUtils.getDateDiff(date, currentDateTime, TimeUnit.SECONDS) + ); + + if (currentDifference <= 45 ) { + return ctx.getString(R.string.stats_followers_seconds_ago); + } + if (currentDifference < 90 ) { + return ctx.getString(R.string.stats_followers_a_minute_ago); + } + + // 90 seconds to 45 minutes + if (currentDifference <= 2700 ) { + long minutes = StatsUtils.roundUp(currentDifference, 60); + String followersMinutes = ctx.getString(R.string.stats_followers_minutes); + return String.format(followersMinutes, minutes); + } + + // 45 to 90 minutes + if (currentDifference <= 5400 ) { + return ctx.getString(R.string.stats_followers_an_hour_ago); + } + + // 90 minutes to 22 hours + if (currentDifference <= 79200 ) { + long hours = StatsUtils.roundUp(currentDifference, 60 * 60); + String followersHours = ctx.getString(R.string.stats_followers_hours); + return String.format(followersHours, hours); + } + + // 22 to 36 hours + if (currentDifference <= 129600 ) { + return ctx.getString(R.string.stats_followers_a_day); + } + + // 36 hours to 25 days + // 86400 secs in a day - 2160000 secs in 25 days + if (currentDifference <= 2160000 ) { + long days = StatsUtils.roundUp(currentDifference, 86400); + String followersDays = ctx.getString(R.string.stats_followers_days); + return String.format(followersDays, days); + } + + // 25 to 45 days + // 3888000 secs in 45 days + if (currentDifference <= 3888000 ) { + return ctx.getString(R.string.stats_followers_a_month); + } + + // 45 to 345 days + // 2678400 secs in a month - 29808000 secs in 345 days + if (currentDifference <= 29808000 ) { + long months = StatsUtils.roundUp(currentDifference, 2678400); + String followersMonths = ctx.getString(R.string.stats_followers_months); + return String.format(followersMonths, months); + } + + // 345 to 547 days (1.5 years) + if (currentDifference <= 47260800 ) { + return ctx.getString(R.string.stats_followers_a_year); + } + + // 548 days+ + // 31536000 secs in a year + long years = StatsUtils.roundUp(currentDifference, 31536000); + String followersYears = ctx.getString(R.string.stats_followers_years); + return String.format(followersYears, years); + + } catch (ParseException e) { + AppLog.e(AppLog.T.STATS, e); + } + + return ""; + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsVideoplaysFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsVideoplaysFragment.java new file mode 100644 index 000000000..3ca853a8f --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsVideoplaysFragment.java @@ -0,0 +1,170 @@ +package org.wordpress.android.ui.stats; + +import android.content.Context; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; + +import org.wordpress.android.R; +import org.wordpress.android.ui.stats.models.SingleItemModel; +import org.wordpress.android.ui.stats.models.VideoPlaysModel; +import org.wordpress.android.ui.stats.service.StatsService; +import org.wordpress.android.util.FormatUtils; + +import java.util.ArrayList; +import java.util.List; + + +public class StatsVideoplaysFragment extends StatsAbstractListFragment { + public static final String TAG = StatsVideoplaysFragment.class.getSimpleName(); + + private VideoPlaysModel mVideos; + + @Override + protected boolean hasDataAvailable() { + return mVideos != null; + } + @Override + protected void saveStatsData(Bundle outState) { + if (hasDataAvailable()) { + outState.putSerializable(ARG_REST_RESPONSE, mVideos); + } + } + @Override + protected void restoreStatsData(Bundle savedInstanceState) { + if (savedInstanceState.containsKey(ARG_REST_RESPONSE)) { + mVideos = (VideoPlaysModel) savedInstanceState.getSerializable(ARG_REST_RESPONSE); + } + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.VideoPlaysUpdated event) { + if (!shouldUpdateFragmentOnUpdateEvent(event)) { + return; + } + + mVideos = event.mVideos; + updateUI(); + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.SectionUpdateError event) { + if (!shouldUpdateFragmentOnErrorEvent(event)) { + return; + } + + mVideos = null; + showErrorUI(event.mError); + } + + + @Override + protected void updateUI() { + if (!isAdded()) { + return; + } + + if (hasVideoplays()) { + ArrayAdapter adapter = new TopPostsAndPagesAdapter(getActivity(), getVideoplays()); + StatsUIHelper.reloadLinearLayout(getActivity(), adapter, mList, getMaxNumberOfItemsToShowInList()); + showHideNoResultsUI(false); + } else { + showHideNoResultsUI(true); + } + } + + private boolean hasVideoplays() { + return mVideos != null + && mVideos.getPlays() != null + && mVideos.getPlays().size() > 0; + } + + private List<SingleItemModel> getVideoplays() { + if (!hasVideoplays()) { + return new ArrayList<SingleItemModel>(0); + } + return mVideos.getPlays(); + } + + @Override + protected boolean isViewAllOptionAvailable() { + return hasVideoplays() && getVideoplays().size() > MAX_NUM_OF_ITEMS_DISPLAYED_IN_LIST; + } + + @Override + protected boolean isExpandableList() { + return false; + } + + private class TopPostsAndPagesAdapter extends ArrayAdapter<SingleItemModel> { + + private final List<SingleItemModel> list; + private final LayoutInflater inflater; + + public TopPostsAndPagesAdapter(Context context, List<SingleItemModel> list) { + super(context, R.layout.stats_list_cell, list); + this.list = list; + inflater = LayoutInflater.from(context); + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + View rowView = convertView; + // reuse views + if (rowView == null) { + rowView = inflater.inflate(R.layout.stats_list_cell, parent, false); + // configure view holder + StatsViewHolder viewHolder = new StatsViewHolder(rowView); + rowView.setTag(viewHolder); + } + + final SingleItemModel currentRowData = list.get(position); + StatsViewHolder holder = (StatsViewHolder) rowView.getTag(); + // fill data + // entries + holder.setEntryTextOrLink(currentRowData.getUrl(), currentRowData.getTitle()); + + // totals + holder.totalsTextView.setText(FormatUtils.formatDecimal(currentRowData.getTotals())); + + // no icon + holder.networkImageView.setVisibility(View.GONE); + + return rowView; + } + } + + @Override + protected int getEntryLabelResId() { + return R.string.stats_entry_video_plays; + } + + @Override + protected int getTotalsLabelResId() { + return R.string.stats_totals_plays; + } + + @Override + protected int getEmptyLabelTitleResId() { + return R.string.stats_empty_video; + } + + @Override + protected int getEmptyLabelDescResId() { + return R.string.stats_empty_video_desc; + } + + @Override + protected StatsService.StatsEndpointsEnum[] sectionsToUpdate() { + return new StatsService.StatsEndpointsEnum[]{ + StatsService.StatsEndpointsEnum.VIDEO_PLAYS + }; + } + + @Override + public String getTitle() { + return getString(R.string.stats_view_videos); + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsViewAllActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsViewAllActivity.java new file mode 100644 index 000000000..0f0c795b5 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsViewAllActivity.java @@ -0,0 +1,318 @@ +package org.wordpress.android.ui.stats; + +import android.app.FragmentManager; +import android.app.FragmentTransaction; +import android.os.Bundle; +import android.support.v7.app.ActionBar; +import android.support.v7.app.AppCompatActivity; +import android.text.TextUtils; +import android.view.MenuItem; +import android.view.View; +import android.widget.TextView; +import android.widget.Toast; + +import org.wordpress.android.R; +import org.wordpress.android.WordPress; +import org.wordpress.android.analytics.AnalyticsTracker; +import org.wordpress.android.ui.ActivityId; +import org.wordpress.android.util.AnalyticsUtils; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.NetworkUtils; +import org.wordpress.android.util.helpers.SwipeToRefreshHelper; +import org.wordpress.android.util.widgets.CustomSwipeRefreshLayout; + +import java.io.Serializable; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; + +import de.greenrobot.event.EventBus; + +/** + * Single item details activity. + */ +public class StatsViewAllActivity extends AppCompatActivity { + + public static final String ARG_STATS_VIEW_ALL_TITLE = "arg_stats_view_all_title"; + private static final String SAVED_STATS_SCROLL_POSITION = "SAVED_STATS_SCROLL_POSITION"; + + private boolean mIsInFront; + private boolean mIsUpdatingStats; + private SwipeToRefreshHelper mSwipeToRefreshHelper; + private ScrollViewExt mOuterScrollView; + + private StatsAbstractListFragment mFragment; + + private int mLocalBlogID = -1; + private StatsTimeframe mTimeframe; + private StatsViewType mStatsViewType; + private String mDate; + private Serializable[] mRestResponse; + private int mOuterPagerSelectedButtonIndex = 0; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.stats_activity_view_all); + + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(true); + } + + setTitle(R.string.stats); + + mOuterScrollView = (ScrollViewExt) findViewById(R.id.scroll_view_stats); + + // pull to refresh setup + mSwipeToRefreshHelper = new SwipeToRefreshHelper(this, (CustomSwipeRefreshLayout) findViewById(R.id.ptr_layout), + new SwipeToRefreshHelper.RefreshListener() { + @Override + public void onRefreshStarted() { + if (!NetworkUtils.checkConnection(getBaseContext())) { + mSwipeToRefreshHelper.setRefreshing(false); + mIsUpdatingStats = false; + return; + } + + if (mIsUpdatingStats) { + AppLog.w(AppLog.T.STATS, "stats are already updating, refresh cancelled"); + return; + } + + refreshStats(); + } + } + ); + + if (savedInstanceState != null) { + mLocalBlogID = savedInstanceState.getInt(StatsActivity.ARG_LOCAL_TABLE_BLOG_ID, -1); + Serializable oldData = savedInstanceState.getSerializable(StatsAbstractFragment.ARG_REST_RESPONSE); + if (oldData != null && oldData instanceof Serializable[]) { + mRestResponse = (Serializable[]) oldData; + } + mTimeframe = (StatsTimeframe) savedInstanceState.getSerializable(StatsAbstractFragment.ARGS_TIMEFRAME); + mDate = savedInstanceState.getString(StatsAbstractFragment.ARGS_SELECTED_DATE); + mStatsViewType = (StatsViewType) savedInstanceState.getSerializable(StatsAbstractFragment.ARGS_VIEW_TYPE); + mOuterPagerSelectedButtonIndex = savedInstanceState.getInt(StatsAbstractListFragment.ARGS_TOP_PAGER_SELECTED_BUTTON_INDEX, 0); + final int yScrollPosition = savedInstanceState.getInt(SAVED_STATS_SCROLL_POSITION); + if(yScrollPosition != 0) { + mOuterScrollView.postDelayed(new Runnable() { + public void run() { + if (!isFinishing()) { + mOuterScrollView.scrollTo(0, yScrollPosition); + } + } + }, StatsConstants.STATS_SCROLL_TO_DELAY); + } + } else if (getIntent() != null) { + Bundle extras = getIntent().getExtras(); + mLocalBlogID = extras.getInt(StatsActivity.ARG_LOCAL_TABLE_BLOG_ID, -1); + mTimeframe = (StatsTimeframe) extras.getSerializable(StatsAbstractFragment.ARGS_TIMEFRAME); + mDate = extras.getString(StatsAbstractFragment.ARGS_SELECTED_DATE); + mStatsViewType = (StatsViewType) extras.getSerializable(StatsAbstractFragment.ARGS_VIEW_TYPE); + mOuterPagerSelectedButtonIndex = extras.getInt(StatsAbstractListFragment.ARGS_TOP_PAGER_SELECTED_BUTTON_INDEX, 0); + + // Set a custom activity title if one was passed + if (!TextUtils.isEmpty(extras.getString(ARG_STATS_VIEW_ALL_TITLE))) { + setTitle(extras.getString(ARG_STATS_VIEW_ALL_TITLE)); + } + } + + if (mStatsViewType == null || mTimeframe == null || mDate == null) { + Toast.makeText(this, getResources().getText(R.string.stats_generic_error), + Toast.LENGTH_SHORT).show(); + finish(); + } + + // Setup the top date label. It's available on those fragments that are affected by the top date selector. + TextView dateTextView = (TextView) findViewById(R.id.stats_summary_date); + switch (mStatsViewType) { + case TOP_POSTS_AND_PAGES: + case REFERRERS: + case CLICKS: + case GEOVIEWS: + case AUTHORS: + case VIDEO_PLAYS: + case SEARCH_TERMS: + dateTextView.setText(getDateForDisplayInLabels(mDate, mTimeframe)); + dateTextView.setVisibility(View.VISIBLE); + break; + default: + dateTextView.setVisibility(View.GONE); + break; + } + + FragmentManager fm = getFragmentManager(); + FragmentTransaction ft = fm.beginTransaction(); + mFragment = (StatsAbstractListFragment) fm.findFragmentByTag("ViewAll-Fragment"); + if (mFragment == null) { + mFragment = getInnerFragment(); + ft.replace(R.id.stats_single_view_fragment, mFragment, "ViewAll-Fragment"); + ft.commitAllowingStateLoss(); + } + + if (savedInstanceState == null) { + AnalyticsUtils.trackWithBlogDetails(AnalyticsTracker.Stat.STATS_VIEW_ALL_ACCESSED, WordPress.getBlog(mLocalBlogID)); + } + } + + @Override + protected void onStop() { + EventBus.getDefault().unregister(this); + super.onStop(); + } + + @Override + protected void onStart() { + super.onStart(); + EventBus.getDefault().register(this); + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.UpdateStatusChanged event) { + if (isFinishing() || !mIsInFront) { + return; + } + mSwipeToRefreshHelper.setRefreshing(event.mUpdating); + mIsUpdatingStats = event.mUpdating; + } + + private String getDateForDisplayInLabels(String date, StatsTimeframe timeframe) { + String prefix = getString(R.string.stats_for); + switch (timeframe) { + case DAY: + return String.format(prefix, StatsUtils.parseDate(date, StatsConstants.STATS_INPUT_DATE_FORMAT, + StatsConstants.STATS_OUTPUT_DATE_MONTH_LONG_DAY_SHORT_FORMAT)); + case WEEK: + try { + SimpleDateFormat sdf = new SimpleDateFormat(StatsConstants.STATS_INPUT_DATE_FORMAT); + final Date parsedDate = sdf.parse(date); + Calendar c = Calendar.getInstance(); + c.setTime(parsedDate); + String endDateLabel = StatsUtils.msToString(c.getTimeInMillis(), StatsConstants.STATS_OUTPUT_DATE_MONTH_LONG_DAY_LONG_FORMAT); + // last day of this week + c.add(Calendar.DAY_OF_WEEK, - 6); + String startDateLabel = StatsUtils.msToString(c.getTimeInMillis(), StatsConstants.STATS_OUTPUT_DATE_MONTH_LONG_DAY_LONG_FORMAT); + return String.format(prefix, startDateLabel + " - " + endDateLabel); + } catch (ParseException e) { + AppLog.e(AppLog.T.UTILS, e); + return ""; + } + case MONTH: + return String.format(prefix, StatsUtils.parseDate(date, StatsConstants.STATS_INPUT_DATE_FORMAT, StatsConstants.STATS_OUTPUT_DATE_MONTH_LONG_FORMAT)); + case YEAR: + return String.format(prefix, StatsUtils.parseDate(date, StatsConstants.STATS_INPUT_DATE_FORMAT, StatsConstants.STATS_OUTPUT_DATE_YEAR_FORMAT)); + } + return ""; + } + + private StatsAbstractListFragment getInnerFragment() { + StatsAbstractListFragment fragment = null; + switch (mStatsViewType) { + case TOP_POSTS_AND_PAGES: + fragment = new StatsTopPostsAndPagesFragment(); + break; + case REFERRERS: + fragment = new StatsReferrersFragment(); + break; + case CLICKS: + fragment = new StatsClicksFragment(); + break; + case GEOVIEWS: + fragment = new StatsGeoviewsFragment(); + break; + case AUTHORS: + fragment = new StatsAuthorsFragment(); + break; + case VIDEO_PLAYS: + fragment = new StatsVideoplaysFragment(); + break; + case COMMENTS: + fragment = new StatsCommentsFragment(); + break; + case TAGS_AND_CATEGORIES: + fragment = new StatsTagsAndCategoriesFragment(); + break; + case PUBLICIZE: + fragment = new StatsPublicizeFragment(); + break; + case FOLLOWERS: + fragment = new StatsFollowersFragment(); + break; + case SEARCH_TERMS: + fragment = new StatsSearchTermsFragment(); + break; + } + + fragment.setTimeframe(mTimeframe); + fragment.setDate(mDate); + + Bundle args = new Bundle(); + args.putInt(StatsActivity.ARG_LOCAL_TABLE_BLOG_ID, mLocalBlogID); + args.putSerializable(StatsAbstractFragment.ARGS_VIEW_TYPE, mStatsViewType); + args.putBoolean(StatsAbstractListFragment.ARGS_IS_SINGLE_VIEW, true); // Always true here + args.putInt(StatsAbstractListFragment.ARGS_TOP_PAGER_SELECTED_BUTTON_INDEX, mOuterPagerSelectedButtonIndex); + args.putSerializable(StatsAbstractFragment.ARG_REST_RESPONSE, mRestResponse); + fragment.setArguments(args); + return fragment; + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + outState.putInt(StatsActivity.ARG_LOCAL_TABLE_BLOG_ID, mLocalBlogID); + outState.putSerializable(StatsAbstractFragment.ARG_REST_RESPONSE, mRestResponse); + outState.putSerializable(StatsAbstractFragment.ARGS_TIMEFRAME, mTimeframe); + outState.putString(StatsAbstractFragment.ARGS_SELECTED_DATE, mDate); + outState.putSerializable(StatsAbstractFragment.ARGS_VIEW_TYPE, mStatsViewType); + outState.putInt(StatsAbstractListFragment.ARGS_TOP_PAGER_SELECTED_BUTTON_INDEX, mOuterPagerSelectedButtonIndex); + if (mOuterScrollView.getScrollY() != 0) { + outState.putInt(SAVED_STATS_SCROLL_POSITION, mOuterScrollView.getScrollY()); + } + super.onSaveInstanceState(outState); + } + + @Override + protected void onResume() { + super.onResume(); + mIsInFront = true; + NetworkUtils.checkConnection(this); // show the error toast if the network is offline + ActivityId.trackLastActivity(ActivityId.STATS_VIEW_ALL); + } + + @Override + protected void onPause() { + super.onPause(); + mIsInFront = false; + mIsUpdatingStats = false; + mSwipeToRefreshHelper.setRefreshing(false); + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + onBackPressed(); + return true; + default: + return super.onOptionsItemSelected(item); + } + } + + private void refreshStats() { + if (mIsUpdatingStats) { + return; + } + if (!NetworkUtils.isNetworkAvailable(this)) { + mSwipeToRefreshHelper.setRefreshing(false); + AppLog.w(AppLog.T.STATS, "ViewAll on "+ mFragment.getTag() + " > no connection, update canceled"); + return; + } + + if (mFragment != null) { + mFragment.refreshStats(); + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsViewHolder.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsViewHolder.java new file mode 100644 index 000000000..0230dec77 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsViewHolder.java @@ -0,0 +1,170 @@ +package org.wordpress.android.ui.stats; + +import android.content.Context; +import android.text.TextUtils; +import android.view.MenuItem; +import android.view.View; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.PopupMenu; +import android.widget.TextView; + +import org.apache.commons.lang.StringEscapeUtils; +import org.wordpress.android.R; +import org.wordpress.android.models.AccountHelper; +import org.wordpress.android.ui.ActivityLauncher; +import org.wordpress.android.ui.WPWebViewActivity; +import org.wordpress.android.ui.stats.models.PostModel; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.UrlUtils; +import org.wordpress.android.widgets.WPNetworkImageView; + +/** + * View holder for stats_list_cell layout + */ +public class StatsViewHolder { + public final TextView entryTextView; + public final TextView totalsTextView; + public final WPNetworkImageView networkImageView; + public final ImageView chevronImageView; + public final ImageView linkImageView; + public final ImageView imgMore; + public final LinearLayout rowContent; + + public StatsViewHolder(View view) { + rowContent = (LinearLayout) view.findViewById(R.id.layout_content); + entryTextView = (TextView) view.findViewById(R.id.stats_list_cell_entry); + totalsTextView = (TextView) view.findViewById(R.id.stats_list_cell_total); + chevronImageView = (ImageView) view.findViewById(R.id.stats_list_cell_chevron); + linkImageView = (ImageView) view.findViewById(R.id.stats_list_cell_link); + networkImageView = (WPNetworkImageView) view.findViewById(R.id.stats_list_cell_image); + + imgMore = (ImageView) view.findViewById(R.id.image_more); + } + + /* + * used by stats fragments to set the entry text, making it a clickable link if a url is passed + */ + public void setEntryTextOrLink(final String linkURL, String linkName) { + if (entryTextView == null) { + return; + } + + entryTextView.setText(linkName); + if (TextUtils.isEmpty(linkURL)) { + entryTextView.setTextColor(entryTextView.getContext().getResources().getColor(R.color.stats_text_color)); + rowContent.setClickable(false); + return; + } + + rowContent.setOnClickListener( + new View.OnClickListener() { + @Override + public void onClick(View view) { + String url = linkURL; + AppLog.d(AppLog.T.UTILS, "Tapped on the Link: " + url); + if (url.startsWith("https://wordpress.com/my-stats") + || url.startsWith("http://wordpress.com/my-stats")) { + // make sure to load the no-chrome version of Stats over https + url = UrlUtils.makeHttps(url); + if (url.contains("?")) { + // add the no chrome parameters if not available + if (!url.contains("?no-chrome") && !url.contains("&no-chrome")) { + url += "&no-chrome"; + } + } else { + url += "?no-chrome"; + } + AppLog.d(AppLog.T.UTILS, "Opening the Authenticated in-app browser : " + url); + // Let's try the global wpcom credentials + String statsAuthenticatedUser = AccountHelper.getDefaultAccount().getUserName(); + if (org.apache.commons.lang.StringUtils.isEmpty(statsAuthenticatedUser)) { + // Still empty. Do not eat the event, but let's open the default Web Browser. + + } + WPWebViewActivity.openUrlByUsingWPCOMCredentials(view.getContext(), + url, statsAuthenticatedUser); + + } else if (url.startsWith("https") || url.startsWith("http")) { + AppLog.d(AppLog.T.UTILS, "Opening the in-app browser: " + url); + WPWebViewActivity.openURL(view.getContext(), url); + } + + } + } + ); + + entryTextView.setTextColor(entryTextView.getContext().getResources().getColor(R.color.stats_link_text_color)); + } + + public void setEntryText(String text) { + entryTextView.setText(text); + rowContent.setClickable(false); + } + + public void setEntryText(String text, int color) { + entryTextView.setTextColor(color); + setEntryText(text); + } + + + /* + * Used by stats fragments to set the entry text, opening the stats details page. + */ + public void setEntryTextOpenDetailsPage(final PostModel currentItem) { + if (entryTextView == null) { + return; + } + + String name = StringEscapeUtils.unescapeHtml(currentItem.getTitle()); + entryTextView.setText(name); + rowContent.setOnClickListener( + new View.OnClickListener() { + @Override + public void onClick(View view) { + ActivityLauncher.viewStatsSinglePostDetails(view.getContext(), currentItem); + } + }); + entryTextView.setTextColor(entryTextView.getContext().getResources().getColor(R.color.stats_link_text_color)); + } + + /* + * Used by stats fragments to create the more btn context menu with the "View" option in it. + * Opening it with reader if possible. + * + */ + public void setMoreButtonOpenInReader(final PostModel currentItem) { + if (imgMore == null) { + return; + } + + imgMore.setVisibility(View.VISIBLE); + imgMore.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + final Context ctx = view.getContext(); + PopupMenu popup = new PopupMenu(ctx, view); + MenuItem menuItem = popup.getMenu().add(ctx.getString(R.string.stats_view)); + menuItem.setOnMenuItemClickListener(new MenuItem.OnMenuItemClickListener() { + @Override + public boolean onMenuItemClick(MenuItem item) { + StatsUtils.openPostInReaderOrInAppWebview(ctx, currentItem); + return true; + } + }); + popup.show(); + } + }); + } + + + public void showChevronIcon() { + linkImageView.setVisibility(View.GONE); + chevronImageView.setVisibility(View.VISIBLE); + } + + public void showLinkIcon() { + linkImageView.setVisibility(View.VISIBLE); + chevronImageView.setVisibility(View.GONE); + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsViewType.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsViewType.java new file mode 100644 index 000000000..2510d61e0 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsViewType.java @@ -0,0 +1,25 @@ +package org.wordpress.android.ui.stats; + +/** + * An enum of the different view types to appear on the stats view. + */ + +public enum StatsViewType { + // TIMEFRAME_SELECTOR, + GRAPH_AND_SUMMARY, + TOP_POSTS_AND_PAGES, + REFERRERS, + CLICKS, + GEOVIEWS, + AUTHORS, + VIDEO_PLAYS, + COMMENTS, + TAGS_AND_CATEGORIES, + PUBLICIZE, + FOLLOWERS, + SEARCH_TERMS, + INSIGHTS_MOST_POPULAR, + INSIGHTS_ALL_TIME, + INSIGHTS_TODAY, + INSIGHTS_LATEST_POST_SUMMARY, +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsVisitorsAndViewsFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsVisitorsAndViewsFragment.java new file mode 100644 index 000000000..230064f6f --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsVisitorsAndViewsFragment.java @@ -0,0 +1,846 @@ +package org.wordpress.android.ui.stats; + +import android.app.Activity; +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CheckBox; +import android.widget.CheckedTextView; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import com.jjoe64.graphview.GraphView; +import com.jjoe64.graphview.GraphViewSeries; + +import org.wordpress.android.R; +import org.wordpress.android.WordPress; +import org.wordpress.android.analytics.AnalyticsTracker; +import org.wordpress.android.models.Blog; +import org.wordpress.android.ui.stats.models.VisitModel; +import org.wordpress.android.ui.stats.models.VisitsModel; +import org.wordpress.android.ui.stats.service.StatsService; +import org.wordpress.android.util.AnalyticsUtils; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.DisplayUtils; +import org.wordpress.android.util.FormatUtils; +import org.wordpress.android.util.NetworkUtils; +import org.wordpress.android.util.StringUtils; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; +import java.util.List; + +public class StatsVisitorsAndViewsFragment extends StatsAbstractFragment + implements StatsBarGraph.OnGestureListener { + + public static final String TAG = StatsVisitorsAndViewsFragment.class.getSimpleName(); + private static final String ARG_SELECTED_GRAPH_BAR = "ARG_SELECTED_GRAPH_BAR"; + private static final String ARG_PREV_NUMBER_OF_BARS = "ARG_PREV_NUMBER_OF_BARS"; + private static final String ARG_SELECTED_OVERVIEW_ITEM = "ARG_SELECTED_OVERVIEW_ITEM"; + private static final String ARG_CHECKBOX_SELECTED = "ARG_CHECKBOX_SELECTED"; + + + private LinearLayout mGraphContainer; + private LinearLayout mNoActivtyThisPeriodContainer; + private StatsBarGraph mGraphView; + private LinearLayout mModuleButtonsContainer; + private TextView mDateTextView; + private String[] mStatsDate; + + private LinearLayout mLegendContainer; + private CheckedTextView mLegendLabel; + private LinearLayout mVisitorsCheckboxContainer; + private CheckBox mVisitorsCheckbox; + private boolean mIsCheckboxChecked = true; + + private OnDateChangeListener mListener; + private OnOverviewItemChangeListener mOverviewItemChangeListener; + + private final OverviewLabel[] overviewItems = {OverviewLabel.VIEWS, OverviewLabel.VISITORS, OverviewLabel.LIKES, + OverviewLabel.COMMENTS}; + + // Restore the following variables on restart + private VisitsModel mVisitsData; + private int mSelectedOverviewItemIndex = 0; + private int mSelectedBarGraphBarIndex = -1; + private int mPrevNumberOfBarsGraph = -1; + + // Container Activity must implement this interface + public interface OnDateChangeListener { + void onDateChanged(String blogID, StatsTimeframe timeframe, String newDate); + } + + // Container Activity must implement this interface + public interface OnOverviewItemChangeListener { + void onOverviewItemChanged(OverviewLabel newItem); + } + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + try { + mListener = (OnDateChangeListener) activity; + } catch (ClassCastException e) { + throw new ClassCastException(activity.toString() + " must implement OnDateChangeListener"); + } + try { + mOverviewItemChangeListener = (OnOverviewItemChangeListener) activity; + } catch (ClassCastException e) { + throw new ClassCastException(activity.toString() + " must implement OnOverviewItemChangeListener"); + } + } + + void setSelectedOverviewItem(OverviewLabel itemToSelect) { + for (int i = 0; i < overviewItems.length; i++) { + if (overviewItems[i] == itemToSelect) { + mSelectedOverviewItemIndex = i; + return; + } + } + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.stats_visitors_and_views_fragment, container, false); + + mDateTextView = (TextView) view.findViewById(R.id.stats_summary_date); + mGraphContainer = (LinearLayout) view.findViewById(R.id.stats_bar_chart_fragment_container); + mModuleButtonsContainer = (LinearLayout) view.findViewById(R.id.stats_pager_tabs); + mNoActivtyThisPeriodContainer = (LinearLayout) view.findViewById(R.id.stats_bar_chart_no_activity); + + mLegendContainer = (LinearLayout) view.findViewById(R.id.stats_legend_container); + mLegendLabel = (CheckedTextView) view.findViewById(R.id.stats_legend_label); + mLegendLabel.setCheckMarkDrawable(null); // Make sure to set a null drawable here. Otherwise the touching area is the same of a TextView + mVisitorsCheckboxContainer = (LinearLayout) view.findViewById(R.id.stats_checkbox_visitors_container); + mVisitorsCheckbox = (CheckBox) view.findViewById(R.id.stats_checkbox_visitors); + mVisitorsCheckbox.setOnClickListener(onCheckboxClicked); + + // Fix an issue on devices with 4.1 or lower, where the Checkbox already uses padding by default internally and overriding it with paddingLeft + // causes the issue report here https://github.com/wordpress-mobile/WordPress-Android/pull/2377#issuecomment-77067993 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + mVisitorsCheckbox.setPadding(getResources().getDimensionPixelSize(R.dimen.margin_medium), 0, 0, 0); + } + + // Make sure we've all the info to build the tab correctly. This is ALWAYS true + if (mModuleButtonsContainer.getChildCount() == overviewItems.length) { + for (int i = 0; i < mModuleButtonsContainer.getChildCount(); i++) { + LinearLayout currentTab = (LinearLayout) mModuleButtonsContainer.getChildAt(i); + boolean isLastItem = i == (overviewItems.length - 1); + boolean isChecked = i == mSelectedOverviewItemIndex; + TabViewHolder currentTabViewHolder = new TabViewHolder(currentTab, overviewItems[i], isChecked, isLastItem); + currentTab.setOnClickListener(TopButtonsOnClickListener); + currentTab.setTag(currentTabViewHolder); + } + mModuleButtonsContainer.setVisibility(View.VISIBLE); + } + + return view; + } + + private class TabViewHolder { + final LinearLayout tab; + final LinearLayout innerContainer; + final TextView label; + final TextView value; + final ImageView icon; + final OverviewLabel labelItem; + boolean isChecked = false; + boolean isLastItem = false; + + public TabViewHolder(LinearLayout currentTab, OverviewLabel labelItem, boolean checked, boolean isLastItem) { + tab = currentTab; + innerContainer = (LinearLayout) currentTab.findViewById(R.id.stats_visitors_and_views_tab_inner_container); + label = (TextView) currentTab.findViewById(R.id.stats_visitors_and_views_tab_label); + label.setText(labelItem.getLabel()); + value = (TextView) currentTab.findViewById(R.id.stats_visitors_and_views_tab_value); + icon = (ImageView) currentTab.findViewById(R.id.stats_visitors_and_views_tab_icon); + this.labelItem = labelItem; + this.isChecked = checked; + this.isLastItem = isLastItem; + updateBackGroundAndIcon(0); + } + + private Drawable getTabIcon() { + switch (labelItem) { + case VISITORS: + return getResources().getDrawable(R.drawable.stats_icon_visitors); + case COMMENTS: + return getResources().getDrawable(R.drawable.stats_icon_comments); + case LIKES: + return getResources().getDrawable(R.drawable.stats_icon_likes); + default: + // Views and when no prev match + return getResources().getDrawable(R.drawable.stats_icon_views); + } + } + + public void updateBackGroundAndIcon(int currentValue) { + if (isChecked) { + value.setTextColor(getResources().getColor(R.color.orange_jazzy)); + } else { + if (currentValue == 0) { + value.setTextColor(getResources().getColor(R.color.grey)); + } else { + value.setTextColor(getResources().getColor(R.color.blue_wordpress)); + } + } + + icon.setImageDrawable(getTabIcon()); + + if (isLastItem) { + if (isChecked) { + tab.setBackgroundResource(R.drawable.stats_visitors_and_views_button_latest_white); + } else { + tab.setBackgroundResource(R.drawable.stats_visitors_and_views_button_latest_blue_light); + } + } else { + if (isChecked) { + tab.setBackgroundResource(R.drawable.stats_visitors_and_views_button_white); + } else { + tab.setBackgroundResource(R.drawable.stats_visitors_and_views_button_blue_light); + } + } + } + + public void setChecked(boolean checked) { + this.isChecked = checked; + } + } + + private final View.OnClickListener TopButtonsOnClickListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + if (!isAdded()) { + return; + } + + //LinearLayout tab = (LinearLayout) v; + TabViewHolder tabViewHolder = (TabViewHolder) v.getTag(); + + if (tabViewHolder.isChecked) { + // already checked. Do nothing + return; + } + + int numberOfTabs = mModuleButtonsContainer.getChildCount(); + int checkedId = -1; + for (int i = 0; i < numberOfTabs; i++) { + LinearLayout currentTab = (LinearLayout) mModuleButtonsContainer.getChildAt(i); + TabViewHolder currentTabViewHolder = (TabViewHolder) currentTab.getTag(); + if (tabViewHolder == currentTab.getTag()) { + checkedId = i; + currentTabViewHolder.setChecked(true); + } else { + currentTabViewHolder.setChecked(false); + } + } + + if (checkedId == -1) + return; + + mSelectedOverviewItemIndex = checkedId; + if (mOverviewItemChangeListener != null) { + mOverviewItemChangeListener.onOverviewItemChanged( + overviewItems[mSelectedOverviewItemIndex] + ); + } + updateUI(); + } + }; + + + private final View.OnClickListener onCheckboxClicked = new View.OnClickListener() { + @Override + public void onClick(View view) { + // Is the view now checked? + mIsCheckboxChecked = ((CheckBox) view).isChecked(); + updateUI(); + } + }; + + + @Override + protected boolean hasDataAvailable() { + return mVisitsData != null; + } + @Override + protected void saveStatsData(Bundle outState) { + if (hasDataAvailable()) { + outState.putSerializable(ARG_REST_RESPONSE, mVisitsData); + } + outState.putInt(ARG_SELECTED_GRAPH_BAR, mSelectedBarGraphBarIndex); + outState.putInt(ARG_PREV_NUMBER_OF_BARS, mPrevNumberOfBarsGraph); + outState.putInt(ARG_SELECTED_OVERVIEW_ITEM, mSelectedOverviewItemIndex); + outState.putBoolean(ARG_CHECKBOX_SELECTED, mVisitorsCheckbox.isChecked()); + } + @Override + protected void restoreStatsData(Bundle savedInstanceState) { + if (savedInstanceState != null) { + if (savedInstanceState.containsKey(ARG_REST_RESPONSE)) { + mVisitsData = (VisitsModel) savedInstanceState.getSerializable(ARG_REST_RESPONSE); + } + if (savedInstanceState.containsKey(ARG_SELECTED_OVERVIEW_ITEM)) { + mSelectedOverviewItemIndex = savedInstanceState.getInt(ARG_SELECTED_OVERVIEW_ITEM, 0); + } + if (savedInstanceState.containsKey(ARG_SELECTED_GRAPH_BAR)) { + mSelectedBarGraphBarIndex = savedInstanceState.getInt(ARG_SELECTED_GRAPH_BAR, -1); + } + if (savedInstanceState.containsKey(ARG_PREV_NUMBER_OF_BARS)) { + mPrevNumberOfBarsGraph = savedInstanceState.getInt(ARG_PREV_NUMBER_OF_BARS, -1); + } + + mIsCheckboxChecked = savedInstanceState.getBoolean(ARG_CHECKBOX_SELECTED, true); + } + } + + @Override + protected void showErrorUI(String label) { + setupNoResultsUI(false); + } + + @Override + protected void showPlaceholderUI() { + setupNoResultsUI(true); + } + + private VisitModel[] getDataToShowOnGraph(VisitsModel visitsData) { + List<VisitModel> visitModels = visitsData.getVisits(); + int numPoints = Math.min(StatsUIHelper.getNumOfBarsToShow(), visitModels.size()); + int currentPointIndex = numPoints - 1; + VisitModel[] visitModelsToShow = new VisitModel[numPoints]; + + for (int i = visitModels.size() -1; i >= 0 && currentPointIndex >= 0; i--) { + VisitModel currentVisitModel = visitModels.get(i); + visitModelsToShow[currentPointIndex] = currentVisitModel; + currentPointIndex--; + } + return visitModelsToShow; + } + + protected void updateUI() { + if (!isAdded()) { + return; + } + + if (mVisitsData == null) { + setupNoResultsUI(false); + return; + } + + final VisitModel[] dataToShowOnGraph = getDataToShowOnGraph(mVisitsData); + if (dataToShowOnGraph == null || dataToShowOnGraph.length == 0) { + setupNoResultsUI(false); + return; + } + + // Hide the "no-activity this period" message + mNoActivtyThisPeriodContainer.setVisibility(View.GONE); + + // Read the selected Tab in the UI + OverviewLabel selectedStatsType = overviewItems[mSelectedOverviewItemIndex]; + + // Update the Legend and enable/disable the visitors checkboxes + mLegendContainer.setVisibility(View.VISIBLE); + mLegendLabel.setText(StringUtils.capitalize(selectedStatsType.getLabel().toLowerCase())); + switch(selectedStatsType) { + case VIEWS: + mVisitorsCheckboxContainer.setVisibility(View.VISIBLE); + mVisitorsCheckbox.setEnabled(true); + mVisitorsCheckbox.setChecked(mIsCheckboxChecked); + break; + default: + mVisitorsCheckboxContainer.setVisibility(View.GONE); + break; + } + + // Setting Up labels and prepare variables that hold series + final String[] horLabels = new String[dataToShowOnGraph.length]; + mStatsDate = new String[dataToShowOnGraph.length]; + GraphView.GraphViewData[] mainSeriesItems = new GraphView.GraphViewData[dataToShowOnGraph.length]; + + GraphView.GraphViewData[] secondarySeriesItems = null; + if (mIsCheckboxChecked && selectedStatsType == OverviewLabel.VIEWS) { + secondarySeriesItems = new GraphView.GraphViewData[dataToShowOnGraph.length]; + } + + // index of days that should be XXX on the graph + final boolean[] weekendDays; + if (getTimeframe() == StatsTimeframe.DAY) { + weekendDays = new boolean[dataToShowOnGraph.length]; + } else { + weekendDays = null; + } + + // Check we have at least one result in the current section. + boolean atLeastOneResultIsAvailable = false; + + // Fill series variables with data + for (int i = 0; i < dataToShowOnGraph.length; i++) { + int currentItemValue = 0; + switch(selectedStatsType) { + case VIEWS: + currentItemValue = dataToShowOnGraph[i].getViews(); + break; + case VISITORS: + currentItemValue = dataToShowOnGraph[i].getVisitors(); + break; + case LIKES: + currentItemValue = dataToShowOnGraph[i].getLikes(); + break; + case COMMENTS: + currentItemValue = dataToShowOnGraph[i].getComments(); + break; + } + mainSeriesItems[i] = new GraphView.GraphViewData(i, currentItemValue); + + if (currentItemValue > 0) { + atLeastOneResultIsAvailable = true; + } + + if (mIsCheckboxChecked && secondarySeriesItems != null) { + secondarySeriesItems[i] = new GraphView.GraphViewData(i, dataToShowOnGraph[i].getVisitors()); + } + + String currentItemStatsDate = dataToShowOnGraph[i].getPeriod(); + horLabels[i] = getDateLabelForBarInGraph(currentItemStatsDate); + mStatsDate[i] = currentItemStatsDate; + + if (weekendDays != null) { + SimpleDateFormat from = new SimpleDateFormat(StatsConstants.STATS_INPUT_DATE_FORMAT); + try { + Date date = from.parse(currentItemStatsDate); + Calendar c = Calendar.getInstance(); + c.setFirstDayOfWeek(Calendar.MONDAY); + c.setTimeInMillis(date.getTime()); + weekendDays[i] = c.get(Calendar.DAY_OF_WEEK) == Calendar.SUNDAY || c.get(Calendar.DAY_OF_WEEK) == Calendar.SATURDAY; + } catch (ParseException e) { + weekendDays[i] = false; + AppLog.e(AppLog.T.STATS, e); + } + } + } + + if (mGraphContainer.getChildCount() >= 1 && mGraphContainer.getChildAt(0) instanceof GraphView) { + mGraphView = (StatsBarGraph) mGraphContainer.getChildAt(0); + } else { + mGraphContainer.removeAllViews(); + mGraphView = new StatsBarGraph(getActivity()); + mGraphContainer.addView(mGraphView); + } + + mGraphView.removeAllSeries(); + + GraphViewSeries mainSeriesOnScreen = new GraphViewSeries(mainSeriesItems); + mainSeriesOnScreen.getStyle().color = getResources().getColor(R.color.stats_bar_graph_main_series); + mainSeriesOnScreen.getStyle().outerColor = getResources().getColor(R.color.translucent_grey_lighten_30); + mainSeriesOnScreen.getStyle().highlightColor = getResources().getColor(R.color.stats_bar_graph_main_series_highlight); + mainSeriesOnScreen.getStyle().outerhighlightColor = getResources().getColor(R.color.stats_bar_graph_outer_highlight); + mainSeriesOnScreen.getStyle().padding = DisplayUtils.dpToPx(getActivity(), 5); + mGraphView.addSeries(mainSeriesOnScreen); + + // Add the Visitors series if it's checked in the legend + if (mIsCheckboxChecked && secondarySeriesItems != null && selectedStatsType == OverviewLabel.VIEWS) { + GraphViewSeries secondarySeries = new GraphViewSeries(secondarySeriesItems); + secondarySeries.getStyle().padding = DisplayUtils.dpToPx(getActivity(), 10); + secondarySeries.getStyle().color = getResources().getColor(R.color.stats_bar_graph_secondary_series); + secondarySeries.getStyle().highlightColor = getResources().getColor(R.color.orange_fire); + mGraphView.addSeries(secondarySeries); + } + + // Setup the Y-axis on Visitors and Views Tabs. + // Views and Visitors tabs have the exact same Y-axis as shifting from one Y-axis to another defeats + // the purpose of making these bars visually easily to compare. + switch(selectedStatsType) { + case VISITORS: + double maxYValue = getMaxYValueForVisitorsAndView(dataToShowOnGraph); + mGraphView.setManualYAxisBounds(maxYValue, 0d); + break; + default: + mGraphView.setManualYAxis(false); + break; + } + + // Set the Graph Style + mGraphView.getGraphViewStyle().setNumHorizontalLabels(dataToShowOnGraph.length); + // Set the maximum size a column can get on the screen in PX + mGraphView.getGraphViewStyle().setMaxColumnWidth( + DisplayUtils.dpToPx(getActivity(), StatsConstants.STATS_GRAPH_BAR_MAX_COLUMN_WIDTH_DP) + ); + mGraphView.setHorizontalLabels(horLabels); + mGraphView.setGestureListener(this); + + // If zero results in the current section disable clicks on the graph and show the dialog. + mNoActivtyThisPeriodContainer.setVisibility(atLeastOneResultIsAvailable ? View.GONE : View.VISIBLE); + mGraphView.setClickable(atLeastOneResultIsAvailable); + + // Draw the background on weekend days + mGraphView.setWeekendDays(weekendDays); + + // Reset the bar selected upon rotation of the device when the no. of bars can change with orientation. + // Only happens on 720DP tablets + if (mPrevNumberOfBarsGraph != -1 && mPrevNumberOfBarsGraph != dataToShowOnGraph.length) { + mSelectedBarGraphBarIndex = -1; + mPrevNumberOfBarsGraph = dataToShowOnGraph.length; + onBarTapped(dataToShowOnGraph.length - 1); + mGraphView.highlightBar(dataToShowOnGraph.length - 1); + return; + } + + mPrevNumberOfBarsGraph = dataToShowOnGraph.length; + int barSelectedOnGraph; + if (mSelectedBarGraphBarIndex == -1) { + // No previous bar was highlighted, highlight the most recent one + barSelectedOnGraph = dataToShowOnGraph.length - 1; + } else if (mSelectedBarGraphBarIndex < dataToShowOnGraph.length) { + barSelectedOnGraph = mSelectedBarGraphBarIndex; + } else { + // A previous bar was highlighted, but it's out of the screen now. This should never happen atm. + barSelectedOnGraph = dataToShowOnGraph.length - 1; + mSelectedBarGraphBarIndex = barSelectedOnGraph; + } + + updateUIBelowTheGraph(barSelectedOnGraph); + mGraphView.highlightBar(barSelectedOnGraph); + } + + // Find the max value in Visitors and Views data. + // Only checks the Views data, since Visitors is for sure less-equals than Views. + private double getMaxYValueForVisitorsAndView(final VisitModel[] dataToShowOnGraph) { + if (dataToShowOnGraph == null || dataToShowOnGraph.length == 0) { + return 0d; + } + double largest = Integer.MIN_VALUE; + + for (VisitModel aDataToShowOnGraph : dataToShowOnGraph) { + int currentItemValue = aDataToShowOnGraph.getViews(); + if (currentItemValue > largest) { + largest = currentItemValue; + } + } + return largest; + } + + //update the area right below the graph + private void updateUIBelowTheGraph(int itemPosition) { + if (!isAdded()) { + return; + } + + if (mVisitsData == null) { + setupNoResultsUI(false); + return; + } + + final VisitModel[] dataToShowOnGraph = getDataToShowOnGraph(mVisitsData); + + // Make sure we've data to show on the screen + if (dataToShowOnGraph.length == 0) { + return; + } + + // This check should never be true, since we put a check on the index in the calling function updateUI() + if (dataToShowOnGraph.length <= itemPosition || itemPosition == -1) { + // Make sure we're not highlighting + itemPosition = dataToShowOnGraph.length -1; + } + + String date = mStatsDate[itemPosition]; + if (date == null) { + AppLog.w(AppLog.T.STATS, "Cannot update the area below the graph if a null date is passed!!"); + return; + } + + mDateTextView.setText(getDateForDisplayInLabels(date, getTimeframe())); + + VisitModel modelTapped = dataToShowOnGraph[itemPosition]; + for (int i=0 ; i < mModuleButtonsContainer.getChildCount(); i++) { + View o = mModuleButtonsContainer.getChildAt(i); + if (o instanceof LinearLayout && o.getTag() instanceof TabViewHolder) { + TabViewHolder tabViewHolder = (TabViewHolder)o.getTag(); + int currentValue = 0; + switch (tabViewHolder.labelItem) { + case VIEWS: + currentValue = modelTapped.getViews(); + break; + case VISITORS: + currentValue = modelTapped.getVisitors(); + break; + case LIKES: + currentValue = modelTapped.getLikes(); + break; + case COMMENTS: + currentValue = modelTapped.getComments(); + break; + } + tabViewHolder.value.setText(FormatUtils.formatDecimal(currentValue)); + tabViewHolder.updateBackGroundAndIcon(currentValue); + } + } + } + + private String getDateForDisplayInLabels(String date, StatsTimeframe timeframe) { + String prefix = getString(R.string.stats_for); + switch (timeframe) { + case DAY: + return String.format(prefix, StatsUtils.parseDate(date, StatsConstants.STATS_INPUT_DATE_FORMAT, StatsConstants.STATS_OUTPUT_DATE_MONTH_LONG_DAY_SHORT_FORMAT)); + case WEEK: + try { + SimpleDateFormat sdf; + Calendar c; + final Date parsedDate; + // Used in bar graph + // first four digits are the year + // followed by Wxx where xx is the month + // followed by Wxx where xx is the day of the month + // ex: 2013W07W22 = July 22, 2013 + sdf = new SimpleDateFormat("yyyy'W'MM'W'dd"); + //Calculate the end of the week + parsedDate = sdf.parse(date); + c = Calendar.getInstance(); + c.setFirstDayOfWeek(Calendar.MONDAY); + c.setTime(parsedDate); + // first day of this week + c.set(Calendar.DAY_OF_WEEK, Calendar.MONDAY ); + String startDateLabel = StatsUtils.msToString(c.getTimeInMillis(), StatsConstants.STATS_OUTPUT_DATE_MONTH_LONG_DAY_LONG_FORMAT); + // last day of this week + c.add(Calendar.DAY_OF_WEEK, + 6); + String endDateLabel = StatsUtils.msToString(c.getTimeInMillis(), StatsConstants.STATS_OUTPUT_DATE_MONTH_LONG_DAY_LONG_FORMAT); + return String.format(prefix, startDateLabel + " - " + endDateLabel); + } catch (ParseException e) { + AppLog.e(AppLog.T.UTILS, e); + return ""; + } + case MONTH: + return String.format(prefix, StatsUtils.parseDate(date, StatsConstants.STATS_INPUT_DATE_FORMAT, StatsConstants.STATS_OUTPUT_DATE_MONTH_LONG_FORMAT)); + case YEAR: + return String.format(prefix, StatsUtils.parseDate(date, StatsConstants.STATS_INPUT_DATE_FORMAT, StatsConstants.STATS_OUTPUT_DATE_YEAR_FORMAT)); + } + return ""; + } + + /** + * Return the date string that is displayed under each bar in the graph + */ + private String getDateLabelForBarInGraph(String dateToFormat) { + switch (getTimeframe()) { + case DAY: + return StatsUtils.parseDate( + dateToFormat, + StatsConstants.STATS_INPUT_DATE_FORMAT, + StatsConstants.STATS_OUTPUT_DATE_MONTH_SHORT_DAY_SHORT_FORMAT + ); + case WEEK: + // first four digits are the year + // followed by Wxx where xx is the month + // followed by Wxx where xx is the day of the month + // ex: 2013W07W22 = July 22, 2013 + return StatsUtils.parseDate(dateToFormat, "yyyy'W'MM'W'dd", StatsConstants.STATS_OUTPUT_DATE_MONTH_SHORT_DAY_SHORT_FORMAT); + case MONTH: + return StatsUtils.parseDate(dateToFormat, "yyyy-MM", "MMM"); + case YEAR: + return StatsUtils.parseDate(dateToFormat, StatsConstants.STATS_INPUT_DATE_FORMAT, StatsConstants.STATS_OUTPUT_DATE_YEAR_FORMAT); + default: + return dateToFormat; + } + } + + private void setupNoResultsUI(boolean isLoading) { + if (!isAdded()) { + return; + } + + // Hide the legend + mLegendContainer.setVisibility(View.GONE); + mVisitorsCheckboxContainer.setVisibility(View.GONE); + + mSelectedBarGraphBarIndex = -1; + Context context = mGraphContainer.getContext(); + if (context != null) { + LayoutInflater inflater = LayoutInflater.from(context); + View emptyBarGraphView = inflater.inflate(R.layout.stats_bar_graph_empty, mGraphContainer, false); + + final TextView emptyLabel = (TextView) emptyBarGraphView.findViewById(R.id.stats_bar_graph_empty_label); + emptyLabel.setText(""); + if (!isLoading) { + mNoActivtyThisPeriodContainer.setVisibility(View.VISIBLE); + } + + if (emptyBarGraphView != null) { + mGraphContainer.removeAllViews(); + mGraphContainer.addView(emptyBarGraphView); + } + } + mDateTextView.setText(""); + + for (int i=0 ; i < mModuleButtonsContainer.getChildCount(); i++) { + View o = mModuleButtonsContainer.getChildAt(i); + if (o instanceof CheckedTextView) { + CheckedTextView currentBtm = (CheckedTextView)o; + OverviewLabel overviewItem = (OverviewLabel)currentBtm.getTag(); + String labelPrefix = overviewItem.getLabel() + "\n 0" ; + currentBtm.setText(labelPrefix); + } + } + } + + @Override + protected String getTitle() { + return getString(R.string.stats_view_visitors_and_views); + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.VisitorsAndViewsUpdated event) { + if (!shouldUpdateFragmentOnUpdateEvent(event)) { + return; + } + + mVisitsData = event.mVisitsAndViews; + mSelectedBarGraphBarIndex = -1; + + // Reset the bar to highlight + if (mGraphView != null) { + mGraphView.resetHighlightBar(); + } + + updateUI(); + } + + @SuppressWarnings("unused") + public void onEventMainThread(StatsEvents.SectionUpdateError event) { + if (!shouldUpdateFragmentOnErrorEvent(event)) { + return; + } + + mVisitsData = null; + mSelectedBarGraphBarIndex = -1; + + // Reset the bar to highlight + if (mGraphView != null) { + mGraphView.resetHighlightBar(); + } + + updateUI(); + } + + @Override + public void onBarTapped(int tappedBar) { + if (!isAdded()) { + return; + } + //AppLog.d(AppLog.T.STATS, " Tapped bar date " + mStatsDate[tappedBar]); + mSelectedBarGraphBarIndex = tappedBar; + updateUIBelowTheGraph(tappedBar); + + if (!NetworkUtils.checkConnection(getActivity())) { + return; + } + + // Update Stats here + String date = mStatsDate[tappedBar]; + if (date == null) { + AppLog.w(AppLog.T.STATS, "A bar was tapped but a null date is received!!"); + return; + } + + //Calculate the correct end date for the selected period + String calculatedDate = null; + + try { + SimpleDateFormat sdf; + Calendar c = Calendar.getInstance(); + c.setFirstDayOfWeek(Calendar.MONDAY); + final Date parsedDate; + switch (getTimeframe()) { + case DAY: + calculatedDate = date; + break; + case WEEK: + // first four digits are the year + // followed by Wxx where xx is the month + // followed by Wxx where xx is the day of the month + // ex: 2013W07W22 = July 22, 2013 + sdf = new SimpleDateFormat("yyyy'W'MM'W'dd"); + //Calculate the end of the week + parsedDate = sdf.parse(date); + c.setTime(parsedDate); + // first day of this week + c.set(Calendar.DAY_OF_WEEK, Calendar.MONDAY); + // last day of this week + c.add(Calendar.DAY_OF_WEEK, +6); + calculatedDate = StatsUtils.msToString(c.getTimeInMillis(), StatsConstants.STATS_INPUT_DATE_FORMAT); + break; + case MONTH: + sdf = new SimpleDateFormat("yyyy-MM"); + //Calculate the end of the month + parsedDate = sdf.parse(date); + c.setTime(parsedDate); + // last day of this month + c.set(Calendar.DAY_OF_MONTH, c.getActualMaximum(Calendar.DAY_OF_MONTH)); + calculatedDate = StatsUtils.msToString(c.getTimeInMillis(), StatsConstants.STATS_INPUT_DATE_FORMAT); + break; + case YEAR: + sdf = new SimpleDateFormat(StatsConstants.STATS_INPUT_DATE_FORMAT); + //Calculate the end of the week + parsedDate = sdf.parse(date); + c.setTime(parsedDate); + c.set(Calendar.MONTH, Calendar.DECEMBER); + c.set(Calendar.DAY_OF_MONTH, 31); + calculatedDate = StatsUtils.msToString(c.getTimeInMillis(), StatsConstants.STATS_INPUT_DATE_FORMAT); + break; + } + } catch (ParseException e) { + AppLog.e(AppLog.T.UTILS, e); + } + + if (calculatedDate == null) { + AppLog.w(AppLog.T.STATS, "A call to request new stats stats is made but date received cannot be parsed!! " + date); + return; + } + + // Update the data below the graph + if (mListener!= null) { + // Should never be null + final Blog currentBlog = WordPress.getBlog(getLocalTableBlogID()); + if (currentBlog != null && currentBlog.getDotComBlogId() != null) { + mListener.onDateChanged(currentBlog.getDotComBlogId(), getTimeframe(), calculatedDate); + } + } + + AnalyticsUtils.trackWithBlogDetails( + AnalyticsTracker.Stat.STATS_TAPPED_BAR_CHART, + WordPress.getBlog(getLocalTableBlogID()) + ); + } + + public enum OverviewLabel { + VIEWS(R.string.stats_views), + VISITORS(R.string.stats_visitors), + LIKES(R.string.stats_likes), + COMMENTS(R.string.stats_comments), + ; + + private final int mLabelResId; + + OverviewLabel(int labelResId) { + mLabelResId = labelResId; + } + + public String getLabel() { + return WordPress.getContext().getString(mLabelResId).toUpperCase(); + } + } + + @Override + protected StatsService.StatsEndpointsEnum[] sectionsToUpdate() { + return new StatsService.StatsEndpointsEnum[]{ + StatsService.StatsEndpointsEnum.VISITS + }; + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsWPLinkMovementMethod.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsWPLinkMovementMethod.java new file mode 100644 index 000000000..30f271981 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsWPLinkMovementMethod.java @@ -0,0 +1,79 @@ +package org.wordpress.android.ui.stats; + +import android.text.Layout; +import android.text.Spannable; +import android.text.style.URLSpan; +import android.view.MotionEvent; +import android.widget.TextView; + +import org.wordpress.android.models.AccountHelper; +import org.wordpress.android.ui.WPWebViewActivity; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.UrlUtils; +import org.wordpress.android.util.WPLinkMovementMethod; + +class StatsWPLinkMovementMethod extends WPLinkMovementMethod { + public static WPLinkMovementMethod getInstance() { + if (mMovementMethod == null) { + mMovementMethod = new StatsWPLinkMovementMethod(); + } + return mMovementMethod; + } + + @Override + public boolean onTouchEvent(TextView widget, Spannable buffer, MotionEvent event) { + int action = event.getAction(); + + if (action == MotionEvent.ACTION_UP) { + int x = (int) event.getX(); + int y = (int) event.getY(); + + x -= widget.getTotalPaddingLeft(); + y -= widget.getTotalPaddingTop(); + + x += widget.getScrollX(); + y += widget.getScrollY(); + + Layout layout = widget.getLayout(); + if (layout == null) { + return super.onTouchEvent(widget, buffer, event); + } + int line = layout.getLineForVertical(y); + int off = layout.getOffsetForHorizontal(line, x); + + URLSpan[] link = buffer.getSpans(off, off, URLSpan.class); + if (link.length != 0) { + String url = link[0].getURL(); + AppLog.d(AppLog.T.UTILS, "Tapped on the Link: " + url); + if (url.startsWith("https://wordpress.com/my-stats") + || url.startsWith("http://wordpress.com/my-stats")) { + // make sure to load the no-chrome version of Stats over https + url = UrlUtils.makeHttps(url); + if (url.contains("?")) { + // add the no chrome parameters if not available + if (!url.contains("?no-chrome") && !url.contains("&no-chrome")) { + url += "&no-chrome"; + } + } else { + url += "?no-chrome"; + } + AppLog.d(AppLog.T.UTILS, "Opening the Authenticated in-app browser : " + url); + // Let's try the global wpcom credentials + String statsAuthenticatedUser = AccountHelper.getDefaultAccount().getUserName(); + if (org.apache.commons.lang.StringUtils.isEmpty(statsAuthenticatedUser)) { + // Still empty. Do not eat the event, but let's open the default Web Browser. + return super.onTouchEvent(widget, buffer, event); + } + WPWebViewActivity.openUrlByUsingWPCOMCredentials(widget.getContext(), + url, statsAuthenticatedUser); + return true; + } else if (url.startsWith("https") || url.startsWith("http")) { + AppLog.d(AppLog.T.UTILS, "Opening the in-app browser: " + url); + WPWebViewActivity.openURL(widget.getContext(), url); + return true; + } + } + } + return super.onTouchEvent(widget, buffer, event); + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsWidgetConfigureActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsWidgetConfigureActivity.java new file mode 100644 index 000000000..cdef4f32a --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsWidgetConfigureActivity.java @@ -0,0 +1,161 @@ +package org.wordpress.android.ui.stats; + +/** + * The configuration screen for the StatsWidgetProvider widget. + */ + +import android.appwidget.AppWidgetManager; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.support.v7.app.ActionBar; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.view.MenuItem; +import android.view.View; +import android.widget.Toast; + +import org.wordpress.android.R; +import org.wordpress.android.WordPress; +import org.wordpress.android.models.AccountHelper; +import org.wordpress.android.models.Blog; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.ToastUtils; + +import java.util.List; +import java.util.Map; + +public class StatsWidgetConfigureActivity extends AppCompatActivity + implements StatsWidgetConfigureAdapter.OnSiteClickListener { + + private StatsWidgetConfigureAdapter mAdapter; + private RecyclerView mRecycleView; + private int mAppWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID; + + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // Find the widget id from the intent. + Intent intent = getIntent(); + Bundle extras = intent.getExtras(); + if (extras != null) { + mAppWidgetId = extras.getInt( + AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID); + } + + // Set the result to CANCELED. This will cause the widget host to cancel out of the widget + // placement if they press the back button. + setResult(RESULT_CANCELED, new Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, mAppWidgetId)); + + // Intent without the widget id, just bail. + if (mAppWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + finish(); + return; + } + + // If not signed into WordPress inform the user + if (!AccountHelper.isSignedIn()) { + ToastUtils.showToast(getBaseContext(), R.string.stats_widget_error_no_account, ToastUtils.Duration.LONG); + finish(); + return; + } + + // If no visible blogs + List<Map<String, Object>> accounts = WordPress.wpDB.getBlogsBy("isHidden = 0", null); + if (accounts.size() == 0) { + ToastUtils.showToast(getBaseContext(), R.string.stats_widget_error_no_visible_blog, ToastUtils.Duration.LONG); + finish(); + return; + } + + // If one blog only, skip config + if (accounts.size() == 1) { + Map<String, Object> account = accounts.get(0); + Integer localID = (Integer) account.get("id"); + addWidgetToScreenAndFinish(localID); + return; + } + + setContentView(R.layout.stats_widget_config_activity); + setNewAdapter(); + setupActionBar(); + setupRecycleView(); + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + int itemId = item.getItemId(); + if (itemId == android.R.id.home) { + onBackPressed(); + return true; + } + return super.onOptionsItemSelected(item); + } + private void setupRecycleView() { + mRecycleView = (RecyclerView) findViewById(R.id.recycler_view); + mRecycleView.setLayoutManager(new LinearLayoutManager(this)); + mRecycleView.setScrollBarStyle(View.SCROLLBARS_OUTSIDE_OVERLAY); + mRecycleView.setItemAnimator(null); + mRecycleView.setAdapter(getAdapter()); + } + + private void setupActionBar() { + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setHomeAsUpIndicator(R.drawable.ic_close_white_24dp); + actionBar.setHomeButtonEnabled(false); + actionBar.setDisplayHomeAsUpEnabled(true); + } + } + + private StatsWidgetConfigureAdapter getAdapter() { + if (mAdapter == null) { + setNewAdapter(); + } + return mAdapter; + } + + private void setNewAdapter() { + Blog blog = WordPress.getCurrentBlog(); + int localBlogId = (blog != null ? blog.getLocalTableBlogId() : 0); + mAdapter = new StatsWidgetConfigureAdapter(this, localBlogId); + mAdapter.setOnSiteClickListener(this); + } + + @Override + public void onSiteClick(StatsWidgetConfigureAdapter.SiteRecord site) { + addWidgetToScreenAndFinish(site.localId); + } + + private void addWidgetToScreenAndFinish(int localID) { + final Blog currentBlog = WordPress.getBlog(localID); + + if (currentBlog == null) { + AppLog.e(AppLog.T.STATS, "The blog with local_blog_id " + localID + " cannot be loaded from the DB."); + Toast.makeText(this, R.string.stats_no_blog, Toast.LENGTH_LONG).show(); + finish(); + return; + } + + if (currentBlog.getDotComBlogId() == null) { + // The blog could be a self-hosted blog with NO Jetpack installed on it + // Or a Jetpack blog whose options are not yet synched in the app + // In both of these cases show a generic message that encourages the user to refresh + // the blog within the app. There are so many different paths here that's better to handle them in the app. + Toast.makeText(this, R.string.stats_widget_error_jetpack_no_blogid, Toast.LENGTH_LONG).show(); + finish(); + return; + } + + final Context context = StatsWidgetConfigureActivity.this; + StatsWidgetProvider.setupNewWidget(context, mAppWidgetId, localID); + // Make sure we pass back the original appWidgetId + Intent resultValue = new Intent(); + resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, mAppWidgetId); + setResult(RESULT_OK, resultValue); + finish(); + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsWidgetConfigureAdapter.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsWidgetConfigureAdapter.java new file mode 100644 index 000000000..cb18d7393 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsWidgetConfigureAdapter.java @@ -0,0 +1,300 @@ +package org.wordpress.android.ui.stats; + +import android.content.Context; +import android.graphics.Typeface; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.os.AsyncTask; +import android.support.v7.widget.RecyclerView; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import org.wordpress.android.R; +import org.wordpress.android.WordPress; +import org.wordpress.android.models.AccountHelper; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.BlogUtils; +import org.wordpress.android.util.GravatarUtils; +import org.wordpress.android.util.MapUtils; +import org.wordpress.android.widgets.WPNetworkImageView; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +class StatsWidgetConfigureAdapter extends RecyclerView.Adapter<StatsWidgetConfigureAdapter.SiteViewHolder> { + + interface OnSiteClickListener { + void onSiteClick(SiteRecord site); + } + + private final int mTextColorNormal; + private final int mTextColorHidden; + + private static int mBlavatarSz; + + private SiteList mSites = new SiteList(); + private final int mCurrentLocalId; + + private final Drawable mSelectedItemBackground; + + private final LayoutInflater mInflater; + + private boolean mShowHiddenSites = false; + private boolean mShowSelfHostedSites = true; + + private OnSiteClickListener mSiteSelectedListener; + + class SiteViewHolder extends RecyclerView.ViewHolder { + private final ViewGroup layoutContainer; + private final TextView txtTitle; + private final TextView txtDomain; + private final WPNetworkImageView imgBlavatar; + private final View divider; + private Boolean isSiteHidden; + + public SiteViewHolder(View view) { + super(view); + layoutContainer = (ViewGroup) view.findViewById(R.id.layout_container); + txtTitle = (TextView) view.findViewById(R.id.text_title); + txtDomain = (TextView) view.findViewById(R.id.text_domain); + imgBlavatar = (WPNetworkImageView) view.findViewById(R.id.image_blavatar); + divider = view.findViewById(R.id.divider); + isSiteHidden = null; + + itemView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + if (mSiteSelectedListener != null) { + int clickedPosition = getAdapterPosition(); + mSiteSelectedListener.onSiteClick(getItem(clickedPosition)); + } + } + }); + } + } + + public StatsWidgetConfigureAdapter(Context context, int currentLocalBlogId) { + super(); + + setHasStableIds(true); + + mCurrentLocalId = currentLocalBlogId; + mInflater = LayoutInflater.from(context); + + mBlavatarSz = context.getResources().getDimensionPixelSize(R.dimen.blavatar_sz); + mTextColorNormal = context.getResources().getColor(R.color.grey_dark); + mTextColorHidden = context.getResources().getColor(R.color.grey); + + mSelectedItemBackground = new ColorDrawable(context.getResources().getColor(R.color.translucent_grey_lighten_20)); + + loadSites(); + } + + @Override + public int getItemCount() { + return mSites.size(); + } + + @Override + public long getItemId(int position) { + return getItem(position).localId; + } + + private SiteRecord getItem(int position) { + return mSites.get(position); + } + + public void setOnSiteClickListener(OnSiteClickListener listener) { + mSiteSelectedListener = listener; + } + + @Override + public SiteViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + View itemView = mInflater.inflate(R.layout.site_picker_listitem, parent, false); + return new SiteViewHolder(itemView); + } + + @Override + public void onBindViewHolder(SiteViewHolder holder, int position) { + SiteRecord site = getItem(position); + + holder.txtTitle.setText(site.getBlogNameOrHomeURL()); + holder.txtDomain.setText(site.homeURL); + holder.imgBlavatar.setImageUrl(site.blavatarUrl, WPNetworkImageView.ImageType.BLAVATAR); + + if (site.localId == mCurrentLocalId) { + holder.layoutContainer.setBackgroundDrawable(mSelectedItemBackground); + } else { + holder.layoutContainer.setBackgroundDrawable(null); + } + + // different styling for visible/hidden sites + if (holder.isSiteHidden == null || holder.isSiteHidden != site.isHidden) { + holder.isSiteHidden = site.isHidden; + holder.txtTitle.setTextColor(site.isHidden ? mTextColorHidden : mTextColorNormal); + holder.txtTitle.setTypeface(holder.txtTitle.getTypeface(), site.isHidden ? Typeface.NORMAL : Typeface.BOLD); + holder.imgBlavatar.setAlpha(site.isHidden ? 0.5f : 1f); + } + + // hide the divider for the last item + boolean isLastItem = (position == getItemCount() - 1); + holder.divider.setVisibility(isLastItem ? View.INVISIBLE : View.VISIBLE); + } + + + private void loadSites() { + if (mIsTaskRunning) { + AppLog.w(AppLog.T.UTILS, "site picker > already loading sites"); + } else { + new LoadSitesTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + } + + /* + * AsyncTask which loads sites from database and populates the adapter + */ + private boolean mIsTaskRunning; + private class LoadSitesTask extends AsyncTask<Void, Void, Void> { + @Override + protected void onPreExecute() { + super.onPreExecute(); + mIsTaskRunning = true; + } + + @Override + protected void onCancelled() { + super.onCancelled(); + mIsTaskRunning = false; + } + + @Override + protected Void doInBackground(Void... params) { + List<Map<String, Object>> blogs; + String[] extraFields = {"isHidden", "dotcomFlag", "homeURL"}; + + blogs = getBlogsForCurrentView(extraFields); + SiteList sites = new SiteList(blogs); + + // sort by blog/host + final long primaryBlogId = AccountHelper.getDefaultAccount().getPrimaryBlogId(); + Collections.sort(sites, new Comparator<SiteRecord>() { + public int compare(SiteRecord site1, SiteRecord site2) { + if (primaryBlogId > 0) { + if (site1.blogId == primaryBlogId) { + return -1; + } else if (site2.blogId == primaryBlogId) { + return 1; + } + } + return site1.getBlogNameOrHomeURL().compareToIgnoreCase(site2.getBlogNameOrHomeURL()); + } + }); + + if (mSites == null || !mSites.isSameList(sites)) { + mSites = sites; + } + + return null; + } + + @Override + protected void onPostExecute(Void results) { + notifyDataSetChanged(); + mIsTaskRunning = false; + } + + private List<Map<String, Object>> getBlogsForCurrentView(String[] extraFields) { + if (mShowHiddenSites) { + if (mShowSelfHostedSites) { + // all self-hosted blogs and all wp.com blogs + return WordPress.wpDB.getBlogsBy(null, extraFields); + } else { + // only wp.com blogs + return WordPress.wpDB.getBlogsBy("dotcomFlag=1", extraFields); + } + } else { + if (mShowSelfHostedSites) { + // all self-hosted blogs plus visible wp.com blogs + return WordPress.wpDB.getBlogsBy("dotcomFlag=0 OR (isHidden=0 AND dotcomFlag=1) ", extraFields); + } else { + // only visible wp.com blogs + return WordPress.wpDB.getBlogsBy("isHidden=0 AND dotcomFlag=1", extraFields); + } + } + } + } + + /** + * SiteRecord is a simplified version of the full account (blog) record + */ + static class SiteRecord { + final int localId; + final int blogId; + final String blogName; + final String homeURL; + final String url; + final String blavatarUrl; + final boolean isDotCom; + final boolean isHidden; + + SiteRecord(Map<String, Object> account) { + localId = MapUtils.getMapInt(account, "id"); + blogId = MapUtils.getMapInt(account, "blogId"); + blogName = BlogUtils.getBlogNameOrHomeURLFromAccountMap(account); + homeURL = BlogUtils.getHomeURLOrHostNameFromAccountMap(account); + url = MapUtils.getMapStr(account, "url"); + blavatarUrl = GravatarUtils.blavatarFromUrl(url, mBlavatarSz); + isDotCom = MapUtils.getMapBool(account, "dotcomFlag"); + isHidden = MapUtils.getMapBool(account, "isHidden"); + } + + String getBlogNameOrHomeURL() { + if (TextUtils.isEmpty(blogName)) { + return homeURL; + } + return blogName; + } + } + + static class SiteList extends ArrayList<SiteRecord> { + SiteList() { } + SiteList(List<Map<String, Object>> accounts) { + if (accounts != null) { + for (Map<String, Object> account : accounts) { + add(new SiteRecord(account)); + } + } + } + + boolean isSameList(SiteList sites) { + if (sites == null || sites.size() != this.size()) { + return false; + } + int i; + for (SiteRecord site: sites) { + i = indexOfSite(site); + if (i == -1 || this.get(i).isHidden != site.isHidden) { + return false; + } + } + return true; + } + + int indexOfSite(SiteRecord site) { + if (site != null && site.blogId > 0) { + for (int i = 0; i < size(); i++) { + if (site.blogId == this.get(i).blogId) { + return i; + } + } + } + return -1; + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsWidgetProvider.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsWidgetProvider.java new file mode 100644 index 000000000..41efa62fe --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/StatsWidgetProvider.java @@ -0,0 +1,541 @@ +package org.wordpress.android.ui.stats; + +import android.app.PendingIntent; +import android.appwidget.AppWidgetManager; +import android.appwidget.AppWidgetProvider; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.util.SparseArray; +import android.view.View; +import android.widget.RemoteViews; + +import com.android.volley.VolleyError; + +import org.apache.commons.lang.ArrayUtils; +import org.apache.commons.lang.StringEscapeUtils; +import org.apache.commons.lang.StringUtils; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.wordpress.android.R; +import org.wordpress.android.WordPress; +import org.wordpress.android.analytics.AnalyticsTracker; +import org.wordpress.android.models.AccountHelper; +import org.wordpress.android.models.Blog; +import org.wordpress.android.ui.main.WPMainActivity; +import org.wordpress.android.ui.prefs.AppPrefs; +import org.wordpress.android.ui.stats.exceptions.StatsError; +import org.wordpress.android.ui.stats.models.VisitModel; +import org.wordpress.android.ui.stats.service.StatsService; +import org.wordpress.android.util.AnalyticsUtils; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.NetworkUtils; + +import java.util.ArrayList; + +public class StatsWidgetProvider extends AppWidgetProvider { + + private static void showMessage(Context context, int[] allWidgets, String message){ + if (allWidgets.length == 0){ + return; + } + + AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context); + + for (int widgetId : allWidgets) { + RemoteViews remoteViews = new RemoteViews(context.getPackageName(), R.layout.stats_widget_layout); + int remoteBlogID = getRemoteBlogIDFromWidgetID(widgetId); + int localId = StatsUtils.getLocalBlogIdFromRemoteBlogId(remoteBlogID); + Blog blog = WordPress.getBlog(localId); + String name; + if (blog != null) { + name = context.getString(R.string.stats_widget_name_for_blog); + name = String.format(name, StringEscapeUtils.unescapeHtml(blog.getNameOrHostUrl())); + } else { + name = context.getString(R.string.stats_widget_name); + } + remoteViews.setTextViewText(R.id.blog_title, name); + + remoteViews.setViewVisibility(R.id.stats_widget_error_container, View.VISIBLE); + remoteViews.setViewVisibility(R.id.stats_widget_values_container, View.GONE); + remoteViews.setTextViewText(R.id.stats_widget_error_text, message); + + Intent intent = new Intent(context, WPMainActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK + | Intent.FLAG_ACTIVITY_CLEAR_TASK); + intent.setAction("android.intent.action.MAIN"); + intent.addCategory("android.intent.category.LAUNCHER"); + PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, 0); + remoteViews.setOnClickPendingIntent(R.id.stats_widget_outer_container, pendingIntent); + + appWidgetManager.updateAppWidget(widgetId, remoteViews); + } + } + + private static void updateTabValue(Context context, RemoteViews remoteViews, int viewId, String text) { + remoteViews.setTextViewText(viewId, text); + if (text.equals("0")) { + remoteViews.setTextColor(viewId, context.getResources().getColor(R.color.grey)); + } + } + + private static void showStatsData(Context context, int[] allWidgets, Blog blog, JSONObject data) { + if (allWidgets.length == 0){ + return; + } + + AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context); + + String name = context.getString(R.string.stats_widget_name_for_blog); + name = String.format(name, StringEscapeUtils.unescapeHtml(blog.getNameOrHostUrl())); + + for (int widgetId : allWidgets) { + RemoteViews remoteViews = new RemoteViews(context.getPackageName(), R.layout.stats_widget_layout); + remoteViews.setTextViewText(R.id.blog_title, name); + + remoteViews.setViewVisibility(R.id.stats_widget_error_container, View.GONE); + remoteViews.setViewVisibility(R.id.stats_widget_values_container, View.VISIBLE); + + // Update Views + updateTabValue(context, remoteViews, R.id.stats_widget_views, data.optString("views", " 0")); + + // Update Visitors + updateTabValue(context, remoteViews, R.id.stats_widget_visitors, data.optString("visitors", " 0")); + + // Update Comments + updateTabValue(context, remoteViews, R.id.stats_widget_comments, data.optString("comments", " 0")); + + // Update Likes + updateTabValue(context, remoteViews, R.id.stats_widget_likes, data.optString("likes", " 0")); + + Intent intent = new Intent(context, StatsActivity.class); + intent.putExtra(StatsActivity.ARG_LOCAL_TABLE_BLOG_ID, blog.getLocalTableBlogId()); + intent.putExtra(StatsActivity.ARG_LAUNCHED_FROM, StatsActivity.StatsLaunchedFrom.STATS_WIDGET); + intent.putExtra(StatsActivity.ARG_DESIRED_TIMEFRAME, StatsTimeframe.DAY); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK); + PendingIntent pendingIntent = PendingIntent.getActivity(context, blog.getLocalTableBlogId(), intent, PendingIntent.FLAG_UPDATE_CURRENT); + + remoteViews.setOnClickPendingIntent(R.id.stats_widget_outer_container, pendingIntent); + appWidgetManager.updateAppWidget(widgetId, remoteViews); + } + } + + private static void ShowCacheIfAvailableOrGenericError(Context context, int remoteBlogID) { + int[] widgetIDs = getWidgetIDsFromRemoteBlogID(remoteBlogID); + if (widgetIDs.length == 0){ + return; + } + + int localId = StatsUtils.getLocalBlogIdFromRemoteBlogId(remoteBlogID); + Blog blog = WordPress.getBlog(localId); + if (blog == null) { + AppLog.e(AppLog.T.STATS, "No blog found in the db!"); + return; + } + + String currentDate = StatsUtils.getCurrentDateTZ(localId); + + // Show cached data if available + JSONObject cache = getCacheDataForBlog(remoteBlogID, currentDate); + if (cache != null) { + showStatsData(context, widgetIDs, blog, cache); + } else { + showMessage(context, widgetIDs, context.getString(R.string.stats_widget_error_generic)); + } + } + + public static void updateWidgetsOnLogout(Context context) { + refreshAllWidgets(context); + } + + public static void updateWidgetsOnLogin(Context context) { + refreshAllWidgets(context); + } + + // This is called by the Stats service in case of error + public static void updateWidgets(Context context, int remoteBlogID, VolleyError error) { + if (error == null) { + AppLog.e(AppLog.T.STATS, "Widget received a VolleyError that is null!"); + return; + } + + // If it's an auth error, show it in the widget UI + if (error instanceof com.android.volley.AuthFailureError) { + int[] widgetIDs = getWidgetIDsFromRemoteBlogID(remoteBlogID); + if (widgetIDs.length == 0){ + return; + } + + // Check if Jetpack or .com + int localId = StatsUtils.getLocalBlogIdFromRemoteBlogId(remoteBlogID); + Blog blog = WordPress.getBlog(localId); + if (blog == null) { + return; + } + + if (blog.isDotcomFlag()) { + // User cannot access stats for this .com blog + showMessage(context, widgetIDs, context.getString(R.string.stats_widget_error_no_permissions)); + } else { + // Not logged into wpcom, or the main .com account of the app is not linked with this blog + showMessage(context, widgetIDs, context.getString(R.string.stats_sign_in_jetpack_different_com_account)); + } + return; + } + + ShowCacheIfAvailableOrGenericError(context, remoteBlogID); + } + + // This is called by the Stats service in case of error + public static void updateWidgets(Context context, int remoteBlogID, StatsError error) { + if (error == null) { + AppLog.e(AppLog.T.STATS, "Widget received a StatsError that is null!"); + return; + } + + ShowCacheIfAvailableOrGenericError(context, remoteBlogID); + } + + // This is called by the Stats service to keep widgets updated + public static void updateWidgets(Context context, int remoteBlogID, VisitModel data) { + AppLog.d(AppLog.T.STATS, "updateWidgets called for the blogID " + remoteBlogID); + + int[] widgetIDs = getWidgetIDsFromRemoteBlogID(remoteBlogID); + if (widgetIDs.length == 0){ + return; + } + + int localId = StatsUtils.getLocalBlogIdFromRemoteBlogId(remoteBlogID); + Blog blog = WordPress.getBlog(localId); + if (blog == null) { + AppLog.e(AppLog.T.STATS, "No blog found in the db!"); + return; + } + + try { + String currentDate = StatsUtils.getCurrentDateTZ(blog.getLocalTableBlogId()); + JSONObject newData = new JSONObject(); + newData.put("blog_id", data.getBlogID()); + newData.put("date", currentDate); + newData.put("views", data.getViews()); + newData.put("visitors", data.getVisitors()); + newData.put("comments", data.getComments()); + newData.put("likes", data.getLikes()); + + // Store new data in cache + String prevDataAsString = AppPrefs.getStatsWidgetsData(); + JSONObject prevData = null; + if (!StringUtils.isEmpty(prevDataAsString)) { + try { + prevData = new JSONObject(prevDataAsString); + } catch (JSONException e) { + AppLog.e(AppLog.T.STATS, e); + } + } + try { + if (prevData == null) { + prevData = new JSONObject(); + } + prevData.put(data.getBlogID(), newData); + AppPrefs.setStatsWidgetsData(prevData.toString()); + } catch (JSONException e) { + AppLog.e(AppLog.T.STATS, e); + } + + // Show data on the screen now! + showStatsData(context, widgetIDs, blog, newData); + } catch (JSONException e) { + AppLog.e(AppLog.T.STATS, e); + } + } + + // This is called to update the App Widget at intervals defined by the updatePeriodMillis attribute in the AppWidgetProviderInfo. + // Also called at booting time! + // This method is NOT called when the user adds the App Widget. + @Override + public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) { + AppLog.d(AppLog.T.STATS, "onUpdate called"); + refreshWidgets(context, appWidgetIds); + } + + /** + * This is called when an instance the App Widget is created for the first time. + * For example, if the user adds two instances of your App Widget, this is only called the first time. + */ + @Override + public void onEnabled(Context context) { + AppLog.d(AppLog.T.STATS, "onEnabled called"); + // Note: don't erase prefs here, since for some reasons this method is called after the booting of the device. + } + + /** + * This is called when the last instance of your App Widget is deleted from the App Widget host. + * This is where you should clean up any work done in onEnabled(Context), such as delete a temporary database. + * @param context The Context in which this receiver is running. + */ + @Override + public void onDisabled(Context context) { + AppLog.d(AppLog.T.STATS, "onDisabled called"); + AnalyticsTracker.track(AnalyticsTracker.Stat.STATS_WIDGET_REMOVED); + AnalyticsTracker.flush(); + AppPrefs.resetStatsWidgetsKeys(); + AppPrefs.resetStatsWidgetsData(); + } + + /** + * This is called every time an App Widget is deleted from the App Widget host. + * @param context The Context in which this receiver is running. + * @param widgetIDs Widget IDs to set blank. We cannot remove widget from home screen. + */ + @Override + public void onDeleted(Context context, int[] widgetIDs) { + setRemoteBlogIDForWidgetIDs(widgetIDs, null); + } + + public static void enqueueStatsRequestForBlog(Context context, String remoteBlogID, String date) { + // start service to get stats + Intent intent = new Intent(context, StatsService.class); + intent.putExtra(StatsService.ARG_BLOG_ID, remoteBlogID); + intent.putExtra(StatsService.ARG_PERIOD, StatsTimeframe.DAY); + intent.putExtra(StatsService.ARG_DATE, date); + intent.putExtra(StatsService.ARG_SECTION, new int[]{StatsService.StatsEndpointsEnum.VISITS.ordinal()}); + context.startService(intent); + } + + private static synchronized JSONObject getCacheDataForBlog(int remoteBlogID, String date) { + String prevDataAsString = AppPrefs.getStatsWidgetsData(); + if (StringUtils.isEmpty(prevDataAsString)) { + AppLog.i(AppLog.T.STATS, "No cache found for the widgets"); + return null; + } + + try { + JSONObject prevData = new JSONObject(prevDataAsString); + if (!prevData.has(String.valueOf(remoteBlogID))) { + AppLog.i(AppLog.T.STATS, "No cache found for the blog ID " + remoteBlogID); + return null; + } + + JSONObject cache = prevData.getJSONObject(String.valueOf(remoteBlogID)); + String dateStoredInCache = cache.optString("date"); + if (date.equals(dateStoredInCache)) { + AppLog.i(AppLog.T.STATS, "Cache found for the blog ID " + remoteBlogID); + return cache; + } else { + AppLog.i(AppLog.T.STATS, "Cache found for the blog ID " + remoteBlogID + " but the date value doesn't match!!"); + return null; + } + } catch (JSONException e) { + AppLog.e(AppLog.T.STATS, e); + return null; + } + } + + public static synchronized boolean isBlogDisplayedInWidget(int remoteBlogID) { + String prevWidgetKeysString = AppPrefs.getStatsWidgetsKeys(); + if (StringUtils.isEmpty(prevWidgetKeysString)) { + return false; + } + try { + JSONObject prevKeys = new JSONObject(prevWidgetKeysString); + JSONArray allKeys = prevKeys.names(); + if (allKeys == null) { + return false; + } + for (int i=0; i < allKeys.length(); i ++) { + String currentKey = allKeys.getString(i); + int currentBlogID = prevKeys.getInt(currentKey); + if (currentBlogID == remoteBlogID) { + return true; + } + } + } catch (JSONException e) { + AppLog.e(AppLog.T.STATS, e); + } + return false; + } + + private static synchronized int[] getWidgetIDsFromRemoteBlogID(int remoteBlogID) { + String prevWidgetKeysString = AppPrefs.getStatsWidgetsKeys(); + if (StringUtils.isEmpty(prevWidgetKeysString)) { + return new int[0]; + } + ArrayList<Integer> widgetIDs = new ArrayList<>(); + + try { + JSONObject prevKeys = new JSONObject(prevWidgetKeysString); + JSONArray allKeys = prevKeys.names(); + if (allKeys == null) { + return new int[0]; + } + for (int i=0; i < allKeys.length(); i ++) { + String currentKey = allKeys.getString(i); + int currentBlogID = prevKeys.getInt(currentKey); + if (currentBlogID == remoteBlogID) { + AppLog.d(AppLog.T.STATS, "The blog with remoteID " + remoteBlogID + " is displayed in the widget " + currentKey); + widgetIDs.add(Integer.parseInt(currentKey)); + } + } + } catch (JSONException e) { + AppLog.e(AppLog.T.STATS, e); + } + return ArrayUtils.toPrimitive(widgetIDs.toArray(new Integer[widgetIDs.size()])); + } + + private static synchronized int getRemoteBlogIDFromWidgetID(int widgetID) { + String prevWidgetKeysString = AppPrefs.getStatsWidgetsKeys(); + if (StringUtils.isEmpty(prevWidgetKeysString)) { + return 0; + } + try { + JSONObject prevKeys = new JSONObject(prevWidgetKeysString); + return prevKeys.optInt(String.valueOf(widgetID), 0); + } catch (JSONException e) { + AppLog.e(AppLog.T.STATS, e); + } + return 0; + } + + + // Store the association between widgetIDs and the remote blog id into prefs. + private static void setRemoteBlogIDForWidgetIDs(int[] widgetIDs, String remoteBlogID) { + String prevWidgetKeysString = AppPrefs.getStatsWidgetsKeys(); + JSONObject prevKeys = null; + if (!StringUtils.isEmpty(prevWidgetKeysString)) { + try { + prevKeys = new JSONObject(prevWidgetKeysString); + } catch (JSONException e) { + AppLog.e(AppLog.T.STATS, e); + } + } + + if (prevKeys == null) { + prevKeys = new JSONObject(); + } + + for (int widgetID : widgetIDs) { + try { + prevKeys.put(String.valueOf(widgetID), remoteBlogID); + AppPrefs.setStatsWidgetsKeys(prevKeys.toString()); + } catch (JSONException e) { + AppLog.e(AppLog.T.STATS, e); + } + } + } + + // This is called by the Widget config activity at the end if the process + static void setupNewWidget(Context context, int widgetID, int localBlogID) { + AppLog.d(AppLog.T.STATS, "setupNewWidget called"); + + Blog blog = WordPress.getBlog(localBlogID); + if (blog == null) { + // it's unlikely that blog is null here. + // This method is called from config activity which has loaded the blog fine. + showMessage(context, new int[]{widgetID}, + context.getString(R.string.stats_widget_error_readd_widget)); + AppLog.e(AppLog.T.STATS, "setupNewWidget: No blog found in the db!"); + return; + } + + // At this point the remote ID cannot be null. + String remoteBlogID = blog.getDotComBlogId(); + // Add the following check just to be safe + if (remoteBlogID == null) { + showMessage(context, new int[]{widgetID}, + context.getString(R.string.stats_widget_error_readd_widget)); + return; + } + + AnalyticsUtils.trackWithBlogDetails(AnalyticsTracker.Stat.STATS_WIDGET_ADDED, remoteBlogID); + AnalyticsTracker.flush(); + + // Store the association between the widget ID and the remote blog id into prefs. + setRemoteBlogIDForWidgetIDs(new int[] {widgetID}, remoteBlogID); + + String currentDate = StatsUtils.getCurrentDateTZ(localBlogID); + + // Load cached data if available and show it immediately + JSONObject cache = getCacheDataForBlog(Integer.parseInt(remoteBlogID), currentDate); + if (cache != null) { + showStatsData(context, new int[] {widgetID}, blog, cache); + return; + } + + if (!NetworkUtils.isNetworkAvailable(context)) { + showMessage(context, new int[] {widgetID}, context.getString(R.string.no_network_title)); + } else { + showMessage(context, new int[] {widgetID}, context.getString(R.string.stats_widget_loading_data)); + enqueueStatsRequestForBlog(context, remoteBlogID, currentDate); + } + } + + + private static void refreshWidgets(Context context, int[] appWidgetIds) { + // If not signed into WordPress inform the user + if (!AccountHelper.isSignedIn()) { + showMessage(context, appWidgetIds, context.getString(R.string.stats_widget_error_no_account)); + return; + } + + SparseArray<ArrayList<Integer>> blogsToWidgetIDs = new SparseArray<>(); + for (int widgetId : appWidgetIds) { + int remoteBlogID = getRemoteBlogIDFromWidgetID(widgetId); + if (remoteBlogID == 0) { + // This could happen on logout when prefs are erased completely since we cannot remove + // widgets programmatically from the screen, or during the configuration of new widgets!!! + AppLog.e(AppLog.T.STATS, "No remote blog ID for widget ID " + widgetId); + showMessage(context, new int[] {widgetId}, context.getString(R.string.stats_widget_error_readd_widget)); + continue; + } + + ArrayList<Integer> widgetIDs = blogsToWidgetIDs.get(remoteBlogID, new ArrayList<Integer>()); + widgetIDs.add(widgetId); + blogsToWidgetIDs.append(remoteBlogID, widgetIDs); + } + + // we now have an optimized data structure for our needs. BlogId -> widgetIDs list + for(int i = 0; i < blogsToWidgetIDs.size(); i++) { + int remoteBlogID = blogsToWidgetIDs.keyAt(i); + // get the object by the key. + ArrayList<Integer> widgetsList = blogsToWidgetIDs.get(remoteBlogID); + int[] currentWidgets = ArrayUtils.toPrimitive(widgetsList.toArray(new Integer[widgetsList.size()])); + + int localId = StatsUtils.getLocalBlogIdFromRemoteBlogId(remoteBlogID); + Blog blog = WordPress.getBlog(localId); + if (localId == 0 || blog == null) { + // No blog in the app + showMessage(context, currentWidgets, context.getString(R.string.stats_widget_error_readd_widget)); + continue; + } + String currentDate = StatsUtils.getCurrentDateTZ(localId); + + // Load cached data if available and show it immediately + JSONObject cache = getCacheDataForBlog(remoteBlogID, currentDate); + if (cache != null) { + showStatsData(context, currentWidgets, blog, cache); + } + + // If network is not available check if NO cache, and show the generic error + // If network is available always start a refresh, and show prev data or the loading in progress message. + if (!NetworkUtils.isNetworkAvailable(context)) { + if (cache == null) { + showMessage(context, currentWidgets, context.getString(R.string.stats_widget_error_generic)); + } + } else { + if (cache == null) { + showMessage(context, currentWidgets, context.getString(R.string.stats_widget_loading_data)); + } + // Make sure to refresh widget data now. + enqueueStatsRequestForBlog(context, String.valueOf(remoteBlogID), currentDate); + } + } + } + + private static void refreshAllWidgets(Context context) { + AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context); + ComponentName thisWidget = new ComponentName(context, StatsWidgetProvider.class); + int[] allWidgetIds = appWidgetManager.getAppWidgetIds(thisWidget); + refreshWidgets(context, allWidgetIds); + } +}
\ No newline at end of file diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/URLSpanNoUnderline.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/URLSpanNoUnderline.java new file mode 100644 index 000000000..2b3e04c6f --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/URLSpanNoUnderline.java @@ -0,0 +1,15 @@ +package org.wordpress.android.ui.stats; + +import android.text.TextPaint; +import android.text.style.URLSpan; + +public class URLSpanNoUnderline extends URLSpan { + public URLSpanNoUnderline(String url) { + super(url); + } + + public void updateDrawState(TextPaint drawState) { + super.updateDrawState(drawState); + drawState.setUnderlineText(false); + } +}
\ No newline at end of file diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/adapters/PostsAndPagesAdapter.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/adapters/PostsAndPagesAdapter.java new file mode 100644 index 000000000..e49cbaf67 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/adapters/PostsAndPagesAdapter.java @@ -0,0 +1,55 @@ +package org.wordpress.android.ui.stats.adapters; + + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; + +import org.wordpress.android.R; +import org.wordpress.android.ui.stats.StatsViewHolder; +import org.wordpress.android.ui.stats.models.PostModel; +import org.wordpress.android.util.FormatUtils; + +import java.util.List; + +public class PostsAndPagesAdapter extends ArrayAdapter<PostModel> { + + private final List<PostModel> list; + private final LayoutInflater inflater; + + public PostsAndPagesAdapter(Context context, List<PostModel> list) { + super(context, R.layout.stats_list_cell, list); + this.list = list; + inflater = LayoutInflater.from(context); + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + View rowView = convertView; + // reuse views + if (rowView == null) { + rowView = inflater.inflate(R.layout.stats_list_cell, parent, false); + // configure view holder + StatsViewHolder viewHolder = new StatsViewHolder(rowView); + rowView.setTag(viewHolder); + } + + final PostModel currentRowData = list.get(position); + StatsViewHolder holder = (StatsViewHolder) rowView.getTag(); + + // Entry + holder.setEntryTextOpenDetailsPage(currentRowData); + + // Setup the more button + holder.setMoreButtonOpenInReader(currentRowData); + + // totals + holder.totalsTextView.setText(FormatUtils.formatDecimal(currentRowData.getTotals())); + + // no icon + holder.networkImageView.setVisibility(View.GONE); + return rowView; + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/datasets/StatsDatabaseHelper.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/datasets/StatsDatabaseHelper.java new file mode 100644 index 000000000..03670cda8 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/datasets/StatsDatabaseHelper.java @@ -0,0 +1,130 @@ +package org.wordpress.android.ui.stats.datasets; + +import android.content.Context; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; + +import org.wordpress.android.util.AppLog; + +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/** + * database for all tracks information + */ +public class StatsDatabaseHelper extends SQLiteOpenHelper { + private static final String DB_NAME = "stats.db"; + private static final int DB_VERSION = 1; + + /* + * database singleton + */ + private static StatsDatabaseHelper mDatabaseHelper; + private final static Object mDbLock = new Object(); + private final Context mContext; + + public static StatsDatabaseHelper getDatabase(Context ctx) { + if (mDatabaseHelper == null) { + synchronized(mDbLock) { + if (mDatabaseHelper == null) { + mDatabaseHelper = new StatsDatabaseHelper(ctx); + // this ensures that onOpen() is called with a writable database (open will fail if app calls getReadableDb() first) + mDatabaseHelper.getWritableDatabase(); + } + } + } + return mDatabaseHelper; + } + + private StatsDatabaseHelper(Context context) { + super(context, DB_NAME, null, DB_VERSION); + mContext = context; + } + + + public static SQLiteDatabase getReadableDb(Context ctx) { + return getDatabase(ctx).getReadableDatabase(); + } + public static SQLiteDatabase getWritableDb(Context ctx) { + return getDatabase(ctx).getWritableDatabase(); + } + + @Override + public void onOpen(SQLiteDatabase db) { + super.onOpen(db); + // Used during development to copy database to external storage and read its content. + // copyDatabase(db); + } + + /* + * drop & recreate all tables (essentially clears the db of all data) + */ + public void reset() { + SQLiteDatabase db = getWritableDatabase(); + db.beginTransaction(); + try { + dropAllTables(db); + createAllTables(db); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + @Override + public void onCreate(SQLiteDatabase db) { + createAllTables(db); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + // for now just reset the db when upgrading, future versions may want to avoid this + // and modify table structures, etc., on upgrade while preserving data + AppLog.i(AppLog.T.STATS, "Upgrading database from version " + oldVersion + " to version " + newVersion); + reset(); + } + + @Override + public void onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion) { + // IMPORTANT: do NOT call super() here - doing so throws a SQLiteException + AppLog.w(AppLog.T.STATS, "Downgrading database from version " + oldVersion + " to version " + newVersion); + reset(); + } + + private void createAllTables(SQLiteDatabase db) { + StatsTable.createTables(db); + } + + private void dropAllTables(SQLiteDatabase db) { + StatsTable.dropTables(db); + } + + /* + * used during development to copy database to external storage so we can access it via DDMS + */ + @SuppressWarnings("unused") + private void copyDatabase(SQLiteDatabase db) { + String copyFrom = db.getPath(); + String copyTo = mContext.getExternalFilesDir(null).getAbsolutePath() + "/" + DB_NAME; + + try { + InputStream input = new FileInputStream(copyFrom); + OutputStream output = new FileOutputStream(copyTo); + + byte[] buffer = new byte[1024]; + int length; + while ((length = input.read(buffer)) > 0) { + output.write(buffer, 0, length); + } + + output.flush(); + output.close(); + input.close(); + } catch (IOException e) { + AppLog.e(AppLog.T.STATS, "failed to copy stats database", e); + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/datasets/StatsTable.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/datasets/StatsTable.java new file mode 100644 index 000000000..27cad108c --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/datasets/StatsTable.java @@ -0,0 +1,226 @@ +package org.wordpress.android.ui.stats.datasets; + +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteStatement; + +import org.wordpress.android.ui.stats.StatsTimeframe; +import org.wordpress.android.ui.stats.service.StatsService.StatsEndpointsEnum; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.SqlUtils; + +public class StatsTable { + + private static final String TABLE_NAME = "tbl_stats"; + public static final int CACHE_TTL_MINUTES = 10; + private static final int MAX_RESPONSE_LEN = (int) (1024 * 1024 * 1.8); // 1.8 MB Approx + + static void createTables(SQLiteDatabase db) { + db.execSQL("CREATE TABLE " + TABLE_NAME + " (" + + " id INTEGER PRIMARY KEY ASC," // Also alias for the built-in rowid: "rowid", "oid", or "_rowid_" + + " blogID INTEGER NOT NULL," // The local blog_id as stored in the WPDB + + " type INTEGER DEFAULT 0," // The type of the stats. TopPost, followers, etc.. + + " timeframe INTEGER DEFAULT 0," // This could be days, week, years - It's an enum + + " date TEXT NOT NULL," + + " jsonData TEXT NOT NULL," + + " maxResult INTEGER DEFAULT 0," + + " page INTEGER DEFAULT 0," + + " timestamp INTEGER NOT NULL," // The unix timestamp of the response + + " UNIQUE (blogID, type, timeframe, date) ON CONFLICT REPLACE" + + ")"); + } + + static void dropTables(SQLiteDatabase db) { + db.execSQL("DROP TABLE IF EXISTS " + TABLE_NAME); + } + + protected static void reset(SQLiteDatabase db) { + dropTables(db); + createTables(db); + } + + + public static String getStats(final Context ctx, final int blogId, final StatsTimeframe timeframe, final String date, + final StatsEndpointsEnum sectionToUpdate, final int maxResultsRequested, final int pageRequested) { + if (ctx == null) { + AppLog.e(AppLog.T.STATS, "Cannot insert a null stats since the passed context is null. Context is required " + + "to access the DB."); + return null; + } + + String sql = "SELECT * FROM " + TABLE_NAME + " WHERE blogID = ? " + + " AND type=?" + + " AND timeframe=?" + + " AND date=?" + + " AND page=?" + + " AND maxResult >=?" + + " ORDER BY timestamp DESC" + + " LIMIT 1"; + + String[] args = { + Integer.toString(blogId), + Integer.toString(sectionToUpdate.ordinal()), + Integer.toString(timeframe.ordinal()), + date, + Integer.toString(pageRequested), + Integer.toString(maxResultsRequested), + }; + + Cursor cursor = StatsDatabaseHelper.getReadableDb(ctx).rawQuery(sql, args); + + try { + if (cursor != null && cursor.moveToFirst()) { + long timestamp = cursor.getLong(cursor.getColumnIndex("timestamp")); + long currentTime = System.currentTimeMillis(); + long deltaMS = currentTime - timestamp; + if (deltaMS < 0) { + // current date is in the past respect to stats date?? Uhhh! + return null; + } + + deltaMS = deltaMS / 1000; // seconds + // check if the cache is fresh + if ((deltaMS / 60) > CACHE_TTL_MINUTES) { + return null; // cache is expired + } + + return cursor.getString(cursor.getColumnIndex("jsonData")); + } else { + return null; + } + } catch (IllegalStateException e) { + AppLog.e(AppLog.T.STATS, e); + } finally { + SqlUtils.closeCursor(cursor); + } + + return null; + } + + public static void insertStats(final Context ctx, final int blogId, final StatsTimeframe timeframe, final String date, + final StatsEndpointsEnum sectionToUpdate, final int maxResultsRequested, final int pageRequested, + final String jsonResponse, final long responseTimestamp) { + + if (ctx == null) { + AppLog.e(AppLog.T.STATS, "Cannot insert a null stats since the passed context is null. Context is required " + + "to access the DB."); + return; + } + + /* + * Android's CursorWindow has a max size of 2MB per row which can be exceeded + * with a very large text column, causing an IllegalStateException when the + * row is read - prevent this by limiting the amount of text that's stored in + * the text column - note that this situation very rarely occurs + * https://github.com/android/platform_frameworks_base/blob/master/core/res/res/values/config.xml#L1268 + * https://github.com/android/platform_frameworks_base/blob/3bdbf644d61f46b531838558fabbd5b990fc4913/core/java/android/database/CursorWindow.java#L103 + */ + + //Check if the response document from the server is less than 1.8MB. getBytes uses UTF-8 on Android. + if (jsonResponse.getBytes().length > MAX_RESPONSE_LEN) { + AppLog.w(AppLog.T.STATS, "Stats JSON response length > max allowed length of 1.8MB. Current response will not be stored in cache."); + return; + } + + SQLiteDatabase db = StatsDatabaseHelper.getWritableDb(ctx); + db.beginTransaction(); + SQLiteStatement stmt = db.compileStatement("INSERT INTO " + TABLE_NAME + " (blogID, type, timeframe, date, " + + "jsonData, maxResult, page, timestamp) VALUES (?1,?2,?3,?4,?5,?6,?7,?8)"); + try { + stmt.bindLong(1, blogId); + stmt.bindLong(2, sectionToUpdate.ordinal()); + stmt.bindLong(3, timeframe.ordinal()); + stmt.bindString(4, date); + stmt.bindString(5, jsonResponse); + stmt.bindLong(6, maxResultsRequested); + stmt.bindLong(7, pageRequested); + stmt.bindLong(8, responseTimestamp); + stmt.execute(); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + SqlUtils.closeStatement(stmt); + } + } + + /** + * Delete expired Stats data from StatsDB + */ + public static boolean deleteOldStats(final Context ctx, final long timestamp) { + if (ctx == null) { + AppLog.e(AppLog.T.STATS, "Cannot delete stats since the passed context is null. Context is required " + + "to access the DB."); + return false; + } + + SQLiteDatabase db = StatsDatabaseHelper.getWritableDb(ctx); + try { + db.beginTransaction(); + int rowDeleted = db.delete(TABLE_NAME, "timestamp <= ?", new String[] { Long.toString(timestamp) }); + db.setTransactionSuccessful(); + AppLog.d(AppLog.T.STATS, "Number of old stats deleted : " + rowDeleted); + return rowDeleted > 1; + } finally { + db.endTransaction(); + } + } + + public static boolean deleteStatsForBlog(final Context ctx, final int blogId) { + if (ctx == null) { + AppLog.e(AppLog.T.STATS, "Cannot delete stats since the passed context is null. Context is required " + + "to access the DB."); + return false; + } + + SQLiteDatabase db = StatsDatabaseHelper.getWritableDb(ctx); + try { + db.beginTransaction(); + int rowDeleted = db.delete(TABLE_NAME, "blogID=?", new String[] {Integer.toString(blogId)}); + db.setTransactionSuccessful(); + AppLog.d(AppLog.T.STATS, "Stats deleted for localBlogID " + blogId); + return rowDeleted > 1; + } finally { + db.endTransaction(); + } + } + + public static boolean deleteStatsForBlog(final Context ctx, final int blogId, final StatsEndpointsEnum sectionToUpdate ) { + if (ctx == null) { + AppLog.e(AppLog.T.STATS, "Cannot delete stats since the passed context is null. Context is required " + + "to access the DB."); + return false; + } + + SQLiteDatabase db = StatsDatabaseHelper.getWritableDb(ctx); + try { + db.beginTransaction(); + int rowDeleted = db.delete(TABLE_NAME, "blogID=? AND type=?", + new String[] {Integer.toString(blogId), Integer.toString(sectionToUpdate.ordinal())} + ); + db.setTransactionSuccessful(); + AppLog.d(AppLog.T.STATS, "Stats deleted for localBlogID " + blogId + " and type " + sectionToUpdate.getRestEndpointPath()); + return rowDeleted > 1; + } finally { + db.endTransaction(); + } + } + + + public static void purgeAll(Context ctx) { + if (ctx == null) { + AppLog.e(AppLog.T.STATS, "Cannot purgeAll stats since the passed context is null. Context is required " + + "to access the DB."); + return; + } + SQLiteDatabase db = StatsDatabaseHelper.getWritableDb(ctx); + db.beginTransaction(); + try { + db.execSQL("DELETE FROM " + TABLE_NAME); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/exceptions/StatsError.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/exceptions/StatsError.java new file mode 100644 index 000000000..8784ff95f --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/exceptions/StatsError.java @@ -0,0 +1,9 @@ +package org.wordpress.android.ui.stats.exceptions; + +import java.io.Serializable; + +public class StatsError extends Exception implements Serializable { + public StatsError(String errorMessage) { + super(errorMessage); + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/models/AuthorModel.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/AuthorModel.java new file mode 100644 index 000000000..51ba25535 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/AuthorModel.java @@ -0,0 +1,119 @@ +package org.wordpress.android.ui.stats.models; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.wordpress.android.ui.stats.StatsUtils; +import org.wordpress.android.util.JSONUtils; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +/** + * A model to represent a Author + */ +public class AuthorModel implements Serializable { + private String mBlogId; + private long mDate; + private String mGroupId; + private String mName; + private String mAvatar; + private int mViews; + private FollowDataModel mFollowData; + private List<PostModel> mPosts; + + public AuthorModel(String mBlogId, String date, String mGroupId, String mName, String mAvatar, int mViews, JSONObject followData) throws JSONException { + this.mBlogId = mBlogId; + setDate(StatsUtils.toMs(date)); + this.mGroupId = mGroupId; + this.mName = mName; + this.mAvatar = mAvatar; + this.mViews = mViews; + if (followData != null) { + this.mFollowData = new FollowDataModel(followData); + } + } + + public AuthorModel(String blogId, String date, JSONObject authorJSON) throws JSONException { + setBlogId(blogId); + setDate(StatsUtils.toMs(date)); + + setGroupId(authorJSON.getString("name")); + setName(authorJSON.getString("name")); + setViews(authorJSON.getInt("views")); + setAvatar(JSONUtils.getString(authorJSON, "avatar")); + + // Follow data could return a boolean false + JSONObject followData = authorJSON.optJSONObject("follow_data"); + if (followData != null) { + this.mFollowData = new FollowDataModel(followData); + } + + JSONArray postsJSON = authorJSON.getJSONArray("posts"); + mPosts = new ArrayList<>(authorJSON.length()); + for (int i = 0; i < postsJSON.length(); i++) { + JSONObject currentPostJSON = postsJSON.getJSONObject(i); + String postId = String.valueOf(currentPostJSON.getInt("id")); + String title = currentPostJSON.getString("title"); + int views = currentPostJSON.getInt("views"); + String url = currentPostJSON.getString("url"); + PostModel currentPost = new PostModel(mBlogId, mDate, postId, title, views, url); + mPosts.add(currentPost); + } + } + + public String getBlogId() { + return mBlogId; + } + + private void setBlogId(String blogId) { + this.mBlogId = blogId; + } + + public long getDate() { + return mDate; + } + + private void setDate(long date) { + this.mDate = date; + } + + public String getGroupId() { + return mGroupId; + } + + private void setGroupId(String groupId) { + this.mGroupId = groupId; + } + + public String getName() { + return mName; + } + + private void setName(String name) { + this.mName = name; + } + + public int getViews() { + return mViews; + } + + private void setViews(int total) { + this.mViews = total; + } + + public FollowDataModel getFollowData() { + return mFollowData; + } + + public String getAvatar() { + return mAvatar; + } + + private void setAvatar(String icon) { + this.mAvatar = icon; + } + + public List<PostModel> getPosts() { return mPosts; } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/models/AuthorsModel.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/AuthorsModel.java new file mode 100644 index 000000000..92b5f6a47 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/AuthorsModel.java @@ -0,0 +1,84 @@ +package org.wordpress.android.ui.stats.models; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.wordpress.android.util.AppLog; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + + +public class AuthorsModel extends BaseStatsModel { + private String mPeriod; + private String mDate; + private String mBlogID; + private int mOtherViews; + private List<AuthorModel> mAuthors; + + public AuthorsModel(String blogID, JSONObject response) throws JSONException { + this.mBlogID = blogID; + this.mPeriod = response.getString("period"); + this.mDate = response.getString("date"); + + JSONObject jDaysObject = response.getJSONObject("days"); + if (jDaysObject.length() == 0) { + throw new JSONException("Invalid document returned from the REST API"); + } + + JSONArray authorsJSONArray; + // Read the first day + Iterator<String> keys = jDaysObject.keys(); + String key = keys.next(); + JSONObject firstDayObject = jDaysObject.getJSONObject(key); + this.mOtherViews = firstDayObject.optInt("other_views"); + authorsJSONArray = firstDayObject.optJSONArray("authors"); + + if (authorsJSONArray != null) { + mAuthors = new ArrayList<>(authorsJSONArray.length()); + for (int i = 0; i < authorsJSONArray.length(); i++) { + try { + JSONObject currentAuthorJSON = authorsJSONArray.getJSONObject(i); + AuthorModel currentAuthor = new AuthorModel(blogID, mDate, currentAuthorJSON); + mAuthors.add(currentAuthor); + } catch (JSONException e) { + AppLog.e(AppLog.T.STATS, "Unexpected Author object " + + "at position " + i + " Response: " + response.toString(), e); + } + } + } + } + + public String getBlogID() { + return mBlogID; + } + + public void setBlogID(String blogID) { + this.mBlogID = blogID; + } + + public String getDate() { + return mDate; + } + + public void setDate(String date) { + this.mDate = date; + } + + public String getPeriod() { + return mPeriod; + } + + public void setPeriod(String period) { + this.mPeriod = period; + } + + public List<AuthorModel> getAuthors() { + return this.mAuthors; + } + + public int getOtherViews() { + return mOtherViews; + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/models/BaseStatsModel.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/BaseStatsModel.java new file mode 100644 index 000000000..814feefd6 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/BaseStatsModel.java @@ -0,0 +1,7 @@ +package org.wordpress.android.ui.stats.models; + +import java.io.Serializable; + +public class BaseStatsModel implements Serializable{ + +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/models/ClickGroupModel.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/ClickGroupModel.java new file mode 100644 index 000000000..660c30767 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/ClickGroupModel.java @@ -0,0 +1,113 @@ +package org.wordpress.android.ui.stats.models; + +import android.text.TextUtils; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.wordpress.android.ui.stats.StatsUtils; +import org.wordpress.android.util.JSONUtils; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +/** + * A model to represent a click group stat + */ +public class ClickGroupModel implements Serializable { + private String mBlogId; + private long mDate; + + private String mGroupId; + private String mName; + private String mIcon; + private int mViews; + private String mUrl; + private List<SingleItemModel> mClicks; + + public ClickGroupModel(String blogId, String date, JSONObject clickGroupJSON) throws JSONException { + setBlogId(blogId); + setDate(StatsUtils.toMs(date)); + + setGroupId(clickGroupJSON.getString("name")); + setName(clickGroupJSON.getString("name")); + setViews(clickGroupJSON.getInt("views")); + setIcon(JSONUtils.getString(clickGroupJSON, "icon")); + + // if URL is set in the response there is one result only. No need to unfold "results" + if (!TextUtils.isEmpty(JSONUtils.getString(clickGroupJSON, "url"))) { + setUrl(JSONUtils.getString(clickGroupJSON, "url")); + } else { + JSONArray childrenJSON = clickGroupJSON.getJSONArray("children"); + mClicks = new ArrayList<>(childrenJSON.length()); + for (int i = 0; i < childrenJSON.length(); i++) { + JSONObject currentResultJSON = childrenJSON.getJSONObject(i); + String name = currentResultJSON.getString("name"); + int totals = currentResultJSON.getInt("views"); + String icon = currentResultJSON.optString("icon"); + String url = currentResultJSON.optString("url"); + SingleItemModel rm = new SingleItemModel(blogId, date, null, name, totals, url, icon); + mClicks.add(rm); + } + } + } + + public String getBlogId() { + return mBlogId; + } + + private void setBlogId(String blogId) { + this.mBlogId = blogId; + } + + public long getDate() { + return mDate; + } + + private void setDate(long date) { + this.mDate = date; + } + + public String getGroupId() { + return mGroupId; + } + + private void setGroupId(String groupId) { + this.mGroupId = groupId; + } + + public String getName() { + return mName; + } + + private void setName(String name) { + this.mName = name; + } + + public int getViews() { + return mViews; + } + + private void setViews(int total) { + this.mViews = total; + } + + public String getUrl() { + return mUrl; + } + + private void setUrl(String url) { + this.mUrl = url; + } + + public String getIcon() { + return mIcon; + } + + private void setIcon(String icon) { + this.mIcon = icon; + } + + public List<SingleItemModel> getClicks() { return mClicks; } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/models/ClicksModel.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/ClicksModel.java new file mode 100644 index 000000000..ef60320aa --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/ClicksModel.java @@ -0,0 +1,90 @@ +package org.wordpress.android.ui.stats.models; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.wordpress.android.util.AppLog; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + + +public class ClicksModel extends BaseStatsModel { + private String mPeriod; + private String mDate; + private String mBlogID; + private int mOtherClicks; + private int mTotalClicks; + private List<ClickGroupModel> mClickGroups; + + public ClicksModel(String blogID, JSONObject response) throws JSONException { + this.mBlogID = blogID; + this.mPeriod = response.getString("period"); + this.mDate = response.getString("date"); + + JSONObject jDaysObject = response.getJSONObject("days"); + if (jDaysObject.length() == 0) { + throw new JSONException("Invalid document returned from the REST API"); + } + + JSONArray jClickGroupsArray; + // Read the first day + Iterator<String> keys = jDaysObject.keys(); + String key = keys.next(); + JSONObject firstDayObject = jDaysObject.getJSONObject(key); + this.mOtherClicks = firstDayObject.getInt("other_clicks"); + this.mTotalClicks = firstDayObject.getInt("total_clicks"); + jClickGroupsArray = firstDayObject.optJSONArray("clicks"); + + if (jClickGroupsArray != null) { + mClickGroups = new ArrayList<>(jClickGroupsArray.length()); + for (int i = 0; i < jClickGroupsArray.length(); i++) { + try { + JSONObject currentGroupJSON = jClickGroupsArray.getJSONObject(i); + ClickGroupModel currentGroupModel = new ClickGroupModel(blogID, mDate, currentGroupJSON); + mClickGroups.add(currentGroupModel); + } catch (JSONException e) { + AppLog.e(AppLog.T.STATS, "Unexpected ClickGroupModel object " + + "at position " + i + " Response: " + response.toString(), e); + } + } + } + } + + public String getBlogID() { + return mBlogID; + } + + public void setBlogID(String blogID) { + this.mBlogID = blogID; + } + + public String getDate() { + return mDate; + } + + public void setDate(String date) { + this.mDate = date; + } + + public String getPeriod() { + return mPeriod; + } + + public void setPeriod(String period) { + this.mPeriod = period; + } + + public List<ClickGroupModel> getClickGroups() { + return this.mClickGroups; + } + + public int getOtherClicks() { + return mOtherClicks; + } + + public int getTotalClicks() { + return mTotalClicks; + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/models/CommentFollowersModel.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/CommentFollowersModel.java new file mode 100644 index 000000000..a970b44bb --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/CommentFollowersModel.java @@ -0,0 +1,63 @@ +package org.wordpress.android.ui.stats.models; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.List; + + +public class CommentFollowersModel extends BaseStatsModel { + private String mBlogID; + private int mPage; + private int mPages; + private int mTotal; + private List<SingleItemModel> mPosts; + + public CommentFollowersModel(String blogID, JSONObject response) throws JSONException { + this.mBlogID = blogID; + this.mPage = response.getInt("page"); + this.mPages = response.getInt("pages"); + this.mTotal = response.getInt("total"); + + JSONArray postsJSONArray = response.optJSONArray("posts"); + if (postsJSONArray != null) { + mPosts = new ArrayList<>(postsJSONArray.length()); + for (int i = 0; i < postsJSONArray.length(); i++) { + JSONObject currentPostJSON = postsJSONArray.getJSONObject(i); + String postId = String.valueOf(currentPostJSON.getInt("id")); + String title = currentPostJSON.getString("title"); + int followers = currentPostJSON.getInt("followers"); + String url = currentPostJSON.getString("url"); + SingleItemModel currentPost = new SingleItemModel(blogID, null, postId, title, followers, url, null); + mPosts.add(currentPost); + } + } + } + + public String getBlogID() { + return mBlogID; + } + + public void setBlogID(String blogID) { + this.mBlogID = blogID; + } + + public List<SingleItemModel> getPosts() { + return this.mPosts; + } + + public int getTotal() { + return mTotal; + } + + public int getPage() { + return mPage; + } + + public int getPages() { + return mPages; + } + +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/models/CommentsModel.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/CommentsModel.java new file mode 100644 index 000000000..ef7bfe6ac --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/CommentsModel.java @@ -0,0 +1,107 @@ +package org.wordpress.android.ui.stats.models; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.wordpress.android.ui.stats.StatsConstants; + +import java.util.ArrayList; +import java.util.List; + + +public class CommentsModel extends BaseStatsModel { + private String mDate; + private String mBlogID; + private int mMonthlyComments; + private int mTotalComments; + private String mMostActiveDay; + private String mMostActiveTime; + private SingleItemModel mMostCommentedPost; + + private List<PostModel> mPosts; + private List<AuthorModel> mAuthors; + + public CommentsModel(String blogID, JSONObject response) throws JSONException { + this.mBlogID = blogID; + this.mDate = response.getString("date"); + + this.mMonthlyComments = response.getInt("monthly_comments"); + this.mTotalComments = response.getInt("total_comments"); + this.mMostActiveDay = response.getString("most_active_day"); + this.mMostActiveTime = response.getString("most_active_time"); + + + JSONArray postsJSONArray = response.optJSONArray("posts"); + if (postsJSONArray != null) { + mPosts = new ArrayList<>(postsJSONArray.length()); + for (int i = 0; i < postsJSONArray.length(); i++) { + JSONObject currentPostJSON = postsJSONArray.getJSONObject(i); + String itemID = String.valueOf(currentPostJSON.getInt("id")); + String name = currentPostJSON.getString("name"); + int totals = currentPostJSON.getInt("comments"); + String link = currentPostJSON.getString("link"); + PostModel currentPost = new PostModel(blogID, mDate, itemID, name, totals, link, StatsConstants.ITEM_TYPE_POST); + mPosts.add(currentPost); + } + } + + JSONArray authorsJSONArray = response.optJSONArray("authors"); + if (authorsJSONArray != null) { + mAuthors = new ArrayList<>(authorsJSONArray.length()); + for (int i = 0; i < authorsJSONArray.length(); i++) { + JSONObject currentAuthorJSON = authorsJSONArray.getJSONObject(i); + String name = currentAuthorJSON.getString("name"); + int comments = currentAuthorJSON.getInt("comments"); + String url = currentAuthorJSON.getString("link"); + String gravatar = currentAuthorJSON.getString("gravatar"); + JSONObject followData = currentAuthorJSON.optJSONObject("follow_data"); + AuthorModel currentAuthor = new AuthorModel(blogID, mDate, url, name, gravatar, comments, followData); + mAuthors.add(currentAuthor); + } + } + } + + public String getBlogID() { + return mBlogID; + } + + public void setBlogID(String blogID) { + this.mBlogID = blogID; + } + + public String getDate() { + return mDate; + } + + public void setDate(String date) { + this.mDate = date; + } + + public List<PostModel> getPosts() { + return this.mPosts; + } + + public List<AuthorModel> getAuthors() { + return this.mAuthors; + } + + public int getTotalComments() { + return mTotalComments; + } + + public int getMonthlyComments() { + return mMonthlyComments; + } + + public String getMostActiveDay() { + return mMostActiveDay; + } + + public String getMostActiveTime() { + return mMostActiveTime; + } + + public SingleItemModel getMostCommentedPost() { + return mMostCommentedPost; + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/models/FollowDataModel.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/FollowDataModel.java new file mode 100644 index 000000000..2c8acb829 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/FollowDataModel.java @@ -0,0 +1,87 @@ +package org.wordpress.android.ui.stats.models; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.Serializable; + +public class FollowDataModel implements Serializable { + + /* + "following-text": "Following", + "is_following": false, + "following-hover-text": "Unfollow", + "blog_id": 6098762, + "blog_url": "http://ilpostodellefragole.wordpress.com", + "blog_title": "Il posto delle fragole", + "site_id": 6098762, + "stat-source": "stats_comments", + "follow-text": "Follow", + "blog_domain": "ilpostodellefragole.wordpress.com" + */ + + private String type; + private String followText; + private String followingText; + private String followingHoverText; + private boolean isFollowing; + private int blogID; + private int siteID; + private String statsSource; + private String blogDomain; + + public transient boolean isRestCallInProgress = false; + + public FollowDataModel(JSONObject followDataJSON) throws JSONException { + this.type = followDataJSON.getString("type"); + JSONObject paramsJSON = followDataJSON.getJSONObject("params"); + this.followText = paramsJSON.getString("follow-text"); + this.followingText = paramsJSON.getString("following-text"); + this.followingHoverText = paramsJSON.getString("following-hover-text"); + this.isFollowing = paramsJSON.getBoolean("is_following"); + this.blogID = paramsJSON.getInt("blog_id"); + this.siteID = paramsJSON.getInt("site_id"); + this.statsSource = paramsJSON.getString("stat-source"); + this.blogDomain = paramsJSON.getString("blog_domain"); + } + + public boolean isFollowing() { + return isFollowing; + } + + public void setIsFollowing(boolean following) { + isFollowing = following; + } + + public int getBlogID() { + return blogID; + } + + public int getSiteID() { + return siteID; + } + + public String getFollowText() { + return followText; + } + + public String getFollowingHoverText() { + return followingHoverText; + } + + public String getFollowingText() { + return followingText; + } + + public String getType() { + return type; + } + + public String getStatsSource() { + return statsSource; + } + + public String getBlogDomain() { + return blogDomain; + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/models/FollowerModel.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/FollowerModel.java new file mode 100644 index 000000000..4efc3bb8c --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/FollowerModel.java @@ -0,0 +1,77 @@ +package org.wordpress.android.ui.stats.models; + +import android.text.TextUtils; + +import org.json.JSONException; +import org.json.JSONObject; +import org.wordpress.android.util.JSONUtils; +import org.wordpress.android.util.UrlUtils; + +import java.io.Serializable; + +public class FollowerModel implements Serializable { + private String mBlogId; + private String mLabel; + private String mAvatar; + private String mUrl; + private FollowDataModel mFollowData; + private String mDateSubscribed; + + public FollowerModel(String mBlogId, JSONObject followerJSONData) throws JSONException{ + this.mBlogId = mBlogId; + this.mLabel = followerJSONData.getString("label"); + + setAvatar(JSONUtils.getString(followerJSONData, "avatar")); + setURL(JSONUtils.getString(followerJSONData, "url")); + + this.mDateSubscribed = followerJSONData.getString("date_subscribed"); + + JSONObject followData = followerJSONData.optJSONObject("follow_data"); + if (followData != null) { + this.mFollowData = new FollowDataModel(followData); + } + } + + public String getBlogId() { + return mBlogId; + } + + public void setBlogId(String blogId) { + this.mBlogId = blogId; + } + + public String getLabel() { + return mLabel; + } + + public String getURL() { + return mUrl; + } + + private boolean setURL(String URL) { + if (!TextUtils.isEmpty(URL) && UrlUtils.isValidUrlAndHostNotNull(URL)) { + this.mUrl = URL; + return true; + } + return false; + } + + public FollowDataModel getFollowData() { + return mFollowData; + } + + public String getAvatar() { + return mAvatar; + } + + + + + private void setAvatar(String icon) { + this.mAvatar = icon; + } + + public String getDateSubscribed() { + return mDateSubscribed; + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/models/FollowersModel.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/FollowersModel.java new file mode 100644 index 000000000..ec7826cc0 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/FollowersModel.java @@ -0,0 +1,76 @@ +package org.wordpress.android.ui.stats.models; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.wordpress.android.util.AppLog; + +import java.util.ArrayList; +import java.util.List; + + +public class FollowersModel extends BaseStatsModel { + private String mBlogID; + private int mPage; + private int mPages; + private int mTotal; + private int mTotalEmail; + private int mTotalWPCom; + private List<FollowerModel> mSubscribers; + + public FollowersModel(String blogID, JSONObject response) throws JSONException { + this.mBlogID = blogID; + this.mPage = response.getInt("page"); + this.mPages = response.getInt("pages"); + this.mTotal = response.getInt("total"); + this.mTotalEmail = response.getInt("total_email"); + this.mTotalWPCom = response.getInt("total_wpcom"); + + JSONArray subscribersJSONArray = response.optJSONArray("subscribers"); + if (subscribersJSONArray != null) { + mSubscribers = new ArrayList<>(subscribersJSONArray.length()); + for (int i = 0; i < subscribersJSONArray.length(); i++) { + JSONObject currentAuthorJSON = subscribersJSONArray.getJSONObject(i); + try { + FollowerModel currentFollower = new FollowerModel(mBlogID, currentAuthorJSON); + mSubscribers.add(currentFollower); + } catch (JSONException e) { + AppLog.e(AppLog.T.STATS, "Unexpected Follower object " + + "at position " + i + " Response: " + response.toString(), e); + } + } + } + } + + public String getBlogID() { + return mBlogID; + } + + public void setBlogID(String blogID) { + this.mBlogID = blogID; + } + + public List<FollowerModel> getFollowers() { + return this.mSubscribers; + } + + public int getTotalEmail() { + return mTotalEmail; + } + + public int getTotalWPCom() { + return mTotalWPCom; + } + + public int getPage() { + return mPage; + } + + public int getPages() { + return mPages; + } + + public int getTotal() { + return mTotal; + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/models/GeoviewModel.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/GeoviewModel.java new file mode 100644 index 000000000..bab8f4541 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/GeoviewModel.java @@ -0,0 +1,47 @@ +package org.wordpress.android.ui.stats.models; + +import java.io.Serializable; + +/** + * A model to represent a geoview stat. + */ +public class GeoviewModel implements Serializable { + private final String mCountryShortName; + private final String mCountryFullName; + private int mViews; + private final String mFlagIconURL; + private final String mFlatFlagIconURL; + + public GeoviewModel(String countryShortName, String countryFullName, int views, String flagIcon, String flatFlagIcon) { + this.mCountryShortName = countryShortName; + this.mCountryFullName = countryFullName; + this.mViews = views; + this.mFlagIconURL = flagIcon; + this.mFlatFlagIconURL = flatFlagIcon; + } + + public String getCountryFullName() { + return mCountryFullName; + } + + public String getCountryShortName() { + return mCountryShortName; + } + + public int getViews() { + return mViews; + } + + public void setViews(int views) { + this.mViews = views; + } + + public String getFlagIconURL() { + return mFlagIconURL; + } + + public String getFlatFlagIconURL() { + return mFlatFlagIconURL; + } + +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/models/GeoviewsModel.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/GeoviewsModel.java new file mode 100644 index 000000000..589147bc0 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/GeoviewsModel.java @@ -0,0 +1,96 @@ +package org.wordpress.android.ui.stats.models; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; + + +public class GeoviewsModel extends BaseStatsModel { + private String mDate; + private String mBlogID; + private int otherViews; + private int totalViews; + private List<GeoviewModel> countries; + + public GeoviewsModel(String blogID, JSONObject response) throws JSONException { + this.mBlogID = blogID; + this.mDate = response.getString("date"); + + JSONObject jDaysObject = response.getJSONObject("days"); + if (jDaysObject.length() == 0) { + throw new JSONException("Invalid document returned from the REST API"); + } + + // Read the first day + Iterator<String> keys = jDaysObject.keys(); + String firstDayKey = keys.next(); + JSONObject firstDayObject = jDaysObject.getJSONObject(firstDayKey); + this.otherViews = firstDayObject.getInt("other_views"); + this.totalViews = firstDayObject.getInt("total_views"); + + JSONObject countryInfoJSON = response.optJSONObject("country-info"); + JSONArray viewsJSON = firstDayObject.optJSONArray("views"); + + if (viewsJSON != null && countryInfoJSON != null) { + countries = new ArrayList<>(viewsJSON.length()); + for (int i = 0; i < viewsJSON.length(); i++) { + JSONObject currentCountryJSON = viewsJSON.getJSONObject(i); + String currentCountryCode = currentCountryJSON.getString("country_code"); + int currentCountryViews = currentCountryJSON.getInt("views"); + String flagIcon = null; + String flatFlagIcon = null; + String countryFullName = null; + JSONObject currentCountryDetails = countryInfoJSON.optJSONObject(currentCountryCode); + if (currentCountryDetails != null) { + flagIcon = currentCountryDetails.optString("flag_icon"); + flatFlagIcon = currentCountryDetails.optString("flat_flag_icon"); + countryFullName = currentCountryDetails.optString("country_full"); + } + GeoviewModel m = new GeoviewModel(currentCountryCode, countryFullName, currentCountryViews, flagIcon, flatFlagIcon); + countries.add(m); + + } + + // Sort the countries by views. + Collections.sort(countries, new java.util.Comparator<GeoviewModel>() { + public int compare(GeoviewModel o1, GeoviewModel o2) { + // descending order + return o2.getViews() - o1.getViews(); + } + }); + } + } + + public String getBlogID() { + return mBlogID; + } + + public void setBlogID(String blogID) { + this.mBlogID = blogID; + } + + public String getDate() { + return mDate; + } + + public void setDate(String date) { + this.mDate = date; + } + + public List<GeoviewModel> getCountries() { + return this.countries; + } + + public int getOtherViews() { + return otherViews; + } + + public int getTotalViews() { + return totalViews; + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/models/InsightsAllTimeModel.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/InsightsAllTimeModel.java new file mode 100644 index 000000000..fd0b02e7b --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/InsightsAllTimeModel.java @@ -0,0 +1,63 @@ +package org.wordpress.android.ui.stats.models; + +import org.json.JSONException; +import org.json.JSONObject; + +public class InsightsAllTimeModel extends BaseStatsModel { + + private String mBlogID; + private String mDate; + private int mVisitors; + private int mViews; + private int mPosts; + private String mViewsBestDay; + private int mViewsBestDayTotal; + + + public InsightsAllTimeModel(String blogID, JSONObject response) throws JSONException { + this.setBlogID(blogID); + this.mDate = response.getString("date"); + JSONObject stats = response.getJSONObject("stats"); + this.mPosts = stats.optInt("posts"); + this.mVisitors = stats.optInt("visitors"); + this.mViews = stats.optInt("views"); + this.mViewsBestDay = stats.getString("views_best_day"); + this.mViewsBestDayTotal = stats.optInt("views_best_day_total"); + } + + public String getBlogID() { + return mBlogID; + } + + private void setBlogID(String blogID) { + this.mBlogID = blogID; + } + + public String getDate() { + return mDate; + } + + public void setDate(String date) { + this.mDate = date; + } + + public int getVisitors() { + return mVisitors; + } + + public int getViews() { + return mViews; + } + + public int getPosts() { + return mPosts; + } + + public String getViewsBestDay() { + return mViewsBestDay; + } + + public int getViewsBestDayTotal() { + return mViewsBestDayTotal; + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/models/InsightsLatestPostDetailsModel.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/InsightsLatestPostDetailsModel.java new file mode 100644 index 000000000..7a70e9e89 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/InsightsLatestPostDetailsModel.java @@ -0,0 +1,23 @@ +package org.wordpress.android.ui.stats.models; + +import org.json.JSONException; +import org.json.JSONObject; + + +public class InsightsLatestPostDetailsModel extends BaseStatsModel { + private String mBlogID; + private int mViews; + + public InsightsLatestPostDetailsModel(String blogID, JSONObject response) throws JSONException { + this.mBlogID = blogID; + this.mViews = response.getInt("views"); + } + + public String getBlogID() { + return mBlogID; + } + + public int getPostViewsCount() { + return mViews; + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/models/InsightsLatestPostModel.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/InsightsLatestPostModel.java new file mode 100644 index 000000000..534348c37 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/InsightsLatestPostModel.java @@ -0,0 +1,87 @@ +package org.wordpress.android.ui.stats.models; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + + +public class InsightsLatestPostModel extends BaseStatsModel { + private String mBlogID; + private String mPostTitle; + private String mPostURL; + private String mPostDate; + private int mPostID; + private int mPostViewsCount = Integer.MIN_VALUE; + private int mPostCommentCount; + private int mPostLikeCount; + private int mPostsFound; // if 0 there are no posts on the blog. + + public InsightsLatestPostModel(String blogID, JSONObject response) throws JSONException { + this.mBlogID = blogID; + + mPostsFound = response.optInt("found", 0); + if (mPostsFound == 0) { + // No latest post found! + return; + } + + JSONArray postsObject = response.getJSONArray("posts"); + if (postsObject.length() == 0) { + throw new JSONException("Invalid document returned from the REST API"); + } + + // Read the first post + JSONObject firstPostObject = postsObject.getJSONObject(0); + + this.mPostID = firstPostObject.getInt("ID"); + this.mPostTitle = firstPostObject.getString("title"); + this.mPostDate = firstPostObject.getString("date"); + this.mPostURL = firstPostObject.getString("URL"); + this.mPostLikeCount = firstPostObject.getInt("like_count"); + + JSONObject discussionObject = firstPostObject.optJSONObject("discussion"); + if (discussionObject != null) { + this.mPostCommentCount = discussionObject.optInt("comment_count", 0); + } + } + + public boolean isLatestPostAvailable() { + return mPostsFound > 0; + } + + public String getBlogID() { + return mBlogID; + } + + public String getPostDate() { + return mPostDate; + } + + public String getPostTitle() { + return mPostTitle; + } + + public String getPostURL() { + return mPostURL; + } + + public int getPostID() { + return mPostID; + } + + public int getPostViewsCount() { + return mPostViewsCount; + } + + public void setPostViewsCount(int postViewsCount) { + this.mPostViewsCount = postViewsCount; + } + + public int getPostCommentCount() { + return mPostCommentCount; + } + + public int getPostLikeCount() { + return mPostLikeCount; + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/models/InsightsPopularModel.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/InsightsPopularModel.java new file mode 100644 index 000000000..640c96ffa --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/InsightsPopularModel.java @@ -0,0 +1,43 @@ +package org.wordpress.android.ui.stats.models; + +import org.json.JSONObject; + +public class InsightsPopularModel extends BaseStatsModel { + private final int mHighestHour; + private final int mHighestDayOfWeek; + private final Double mHighestDayPercent; + private final Double mHighestHourPercent; + private String mBlogID; + + public InsightsPopularModel(String blogID, JSONObject response) { + this.setBlogID(blogID); + this.mHighestDayOfWeek = response.optInt("highest_day_of_week"); + this.mHighestHour = response.optInt("highest_hour"); + this.mHighestDayPercent = response.optDouble("highest_day_percent"); + this.mHighestHourPercent = response.optDouble("highest_hour_percent"); + } + + public String getBlogID() { + return mBlogID; + } + + private void setBlogID(String blogID) { + this.mBlogID = blogID; + } + + public int getHighestHour() { + return mHighestHour; + } + + public int getHighestDayOfWeek() { + return mHighestDayOfWeek; + } + + public Double getHighestDayPercent() { + return mHighestDayPercent; + } + + public Double getHighestHourPercent() { + return mHighestHourPercent; + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/models/InsightsTodayModel.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/InsightsTodayModel.java new file mode 100644 index 000000000..c633bc2e6 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/InsightsTodayModel.java @@ -0,0 +1,69 @@ +package org.wordpress.android.ui.stats.models; + +import org.json.JSONException; +import org.json.JSONObject; + +public class InsightsTodayModel extends BaseStatsModel { + + private String mBlogID; + private String mDate; + private String mPeriod; + private int mVisitors; + private int mViews; + private int mLikes; + private int mReblogs; + private int mComments; + private int mFollowers; + + public InsightsTodayModel(String blogID, JSONObject response) throws JSONException { + this.setBlogID(blogID); + this.mDate = response.getString("date"); + this.mPeriod = response.getString("period"); + this.mViews = response.optInt("views"); + this.mVisitors = response.optInt("visitors"); + this.mLikes = response.optInt("likes"); + this.mReblogs = response.optInt("reblogs"); + this.mComments = response.optInt("comments"); + this.mFollowers = response.optInt("followers"); + } + + public String getBlogID() { + return mBlogID; + } + + private void setBlogID(String blogID) { + this.mBlogID = blogID; + } + + public String getDate() { + return mDate; + } + + public void setDate(String date) { + this.mDate = date; + } + + public int getReblogs() { + return mReblogs; + } + + public int getComments() { + return mComments; + } + + public int getFollowers() { + return mFollowers; + } + + public int getLikes() { + return mLikes; + } + + public int getViews() { + return mViews; + } + + public int getVisitors() { + return mVisitors; + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/models/PostModel.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/PostModel.java new file mode 100644 index 000000000..40a37b8e1 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/PostModel.java @@ -0,0 +1,30 @@ +package org.wordpress.android.ui.stats.models; + +import org.wordpress.android.ui.stats.StatsConstants; +import org.wordpress.android.ui.stats.StatsUtils; + +import java.io.Serializable; + +public class PostModel extends SingleItemModel implements Serializable { + + private final String mPostType; + + public PostModel(String blogId, String date, String itemID, String title, int totals, String url, String postType) { + super(blogId, date, itemID, title, totals, url, null); + this.mPostType = postType; + } + + public PostModel(String blogId, long date, String itemID, String title, int totals, String url) { + super(blogId, date, itemID, title, totals, url, null); + this.mPostType = StatsConstants.ITEM_TYPE_POST; + } + + public PostModel(String blogId, String itemID, String title, String url, String postType) { + super(blogId, StatsUtils.getCurrentDate(), itemID, title, 0, url, null); + this.mPostType = postType; + } + + public String getPostType() { + return mPostType; + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/models/PostViewsModel.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/PostViewsModel.java new file mode 100644 index 000000000..5beb0df95 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/PostViewsModel.java @@ -0,0 +1,370 @@ +package org.wordpress.android.ui.stats.models; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.wordpress.android.util.AppLog; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; + +public class PostViewsModel implements Serializable { + private String mOriginalResponse; + + private int mHighestMonth, mHighestDayAverage, mHighestWeekAverage; + private String mDate; + private VisitModel[] mDayViews; //Used to build the graph + private List<Year> mYears; + private List<Year> mAverages; + private List<Week> mWeeks; + + public String getDate() { + return mDate; + } + + public String getOriginalResponse() { + return mOriginalResponse; + } + + public VisitModel[] getDayViews() { + return mDayViews; + } + + public int getHighestMonth() { + return mHighestMonth; + } + + public int getHighestDayAverage() { + return mHighestDayAverage; + } + + public int getHighestWeekAverage() { + return mHighestWeekAverage; + } + + public List<Year> getYears() { + return mYears; + } + + public List<Year> getAverages() { + return mAverages; + } + + public List<Week> getWeeks() { + return mWeeks; + } + + + public PostViewsModel(String response) throws JSONException { + this.mOriginalResponse = response; + JSONObject responseObj = new JSONObject(response); + parseResponseObject(responseObj); + } + + public PostViewsModel(JSONObject response) throws JSONException { + if (response == null) { + return; + } + this.mOriginalResponse = response.toString(); + parseResponseObject(response); + } + + private void parseResponseObject(JSONObject response) throws JSONException { + + mDate = response.getString("date"); + mHighestDayAverage = response.getInt("highest_day_average"); + mHighestWeekAverage = response.getInt("highest_week_average"); + mHighestMonth = response.getInt("highest_month"); + mYears = new LinkedList<>(); + mAverages = new LinkedList<>(); + mWeeks = new LinkedList<>(); + + JSONArray dataJSON = response.getJSONArray("data"); + if (dataJSON != null) { + // Read the position/index of each field in the response + JSONArray fieldsJSON = response.getJSONArray("fields"); + HashMap<String, Integer> fieldColumnsMapping; + try { + fieldColumnsMapping = new HashMap<>(2); + for (int i = 0; i < fieldsJSON.length(); i++) { + final String field = fieldsJSON.getString(i); + fieldColumnsMapping.put(field, i); + } + } catch (JSONException e) { + AppLog.e(AppLog.T.STATS, "Cannot read the fields indexes from the JSON response", e); + throw e; + } + + VisitModel[] visitModels = new VisitModel[dataJSON.length()]; + int viewsColumnIndex = fieldColumnsMapping.get("views"); + int periodColumnIndex = fieldColumnsMapping.get("period"); + + for (int i = 0; i < dataJSON.length(); i++) { + try { + JSONArray currentDayData = dataJSON.getJSONArray(i); + VisitModel currentVisitModel = new VisitModel(); + currentVisitModel.setPeriod(currentDayData.getString(periodColumnIndex)); + currentVisitModel.setViews(currentDayData.getInt(viewsColumnIndex)); + visitModels[i] = currentVisitModel; + } catch (JSONException e) { + AppLog.e(AppLog.T.STATS, "Cannot create the Visit at index " + i, e); + } + } + mDayViews = visitModels; + } else { + mDayViews = null; + } + + parseYears(response); + parseAverages(response); + parseWeeks(response); + } + + private String[] orderKeys(Iterator keys, int numberOfKeys) { + // Keys could not be ordered fine. Reordering them. + String[] orderedKeys = new String[numberOfKeys]; + int i = 0; + while (keys.hasNext()) { + orderedKeys[i] = (String)keys.next(); + i++; + } + Arrays.sort(orderedKeys); + return orderedKeys; + } + + private void parseYears(JSONObject response) { + // Parse the Years section + try { + JSONObject yearsJSON = response.getJSONObject("years"); + // Keys could not be ordered fine. Reordering them. + String[] orderedKeys = orderKeys(yearsJSON.keys(), yearsJSON.length()); + + for (String currentYearKey : orderedKeys) { + Year currentYear = new Year(); + currentYear.setLabel(currentYearKey); + + JSONObject currentYearObj = yearsJSON.getJSONObject(currentYearKey); + int total = currentYearObj.getInt("total"); + currentYear.setTotal(total); + + JSONObject monthsJSON = currentYearObj.getJSONObject("months"); + Iterator<String> monthsKeys = monthsJSON.keys(); + List<Month> monthsList = new ArrayList<>(monthsJSON.length()); + while (monthsKeys.hasNext()) { + String currentMonthKey = monthsKeys.next(); + int currentMonthVisits = monthsJSON.getInt(currentMonthKey); + monthsList.add(new Month(currentMonthKey, currentMonthVisits)); + } + + Collections.sort(monthsList, new Comparator<Month>() { + public int compare(Month o1, Month o2) { + int v1 = Integer.parseInt(o1.getMonth()); + int v2 = Integer.parseInt(o2.getMonth()); + // ascending order + return v1 - v2; + } + }); + + currentYear.setMonths(monthsList); + mYears.add(currentYear); + } + } catch (JSONException e) { + AppLog.e(AppLog.T.STATS, "Cannot parse the Years section", e); + } + } + + private void parseAverages(JSONObject response) { + // Parse the Averages section + try { + JSONObject averagesJSON = response.getJSONObject("averages"); + // Keys could not be ordered fine. Reordering them. + String[] orderedKeys = orderKeys(averagesJSON.keys(), averagesJSON.length()); + + for (String currentJSONKey : orderedKeys) { + Year currentAverage = new Year(); + currentAverage.setLabel(currentJSONKey); + + JSONObject currentAverageJSONObj = averagesJSON.getJSONObject(currentJSONKey); + currentAverage.setTotal(currentAverageJSONObj.getInt("overall")); + + JSONObject monthsJSON = currentAverageJSONObj.getJSONObject("months"); + Iterator<String> monthsKeys = monthsJSON.keys(); + List<Month> monthsList = new ArrayList<>(monthsJSON.length()); + while (monthsKeys.hasNext()) { + String currentMonthKey = monthsKeys.next(); + int currentMonthVisits = monthsJSON.getInt(currentMonthKey); + monthsList.add(new Month(currentMonthKey, currentMonthVisits)); + } + Collections.sort(monthsList, new java.util.Comparator<Month>() { + public int compare(Month o1, Month o2) { + int v1 = Integer.parseInt(o1.getMonth()); + int v2 = Integer.parseInt(o2.getMonth()); + // ascending order + return v1 - v2; + } + }); + + currentAverage.setMonths(monthsList); + mAverages.add(currentAverage); + } + } catch (JSONException e) { + AppLog.e(AppLog.T.STATS, "Cannot parse the Averages section", e); + } + } + + private void parseWeeks(JSONObject response) { + // Parse the Weeks section + try { + JSONArray weeksJSON = response.getJSONArray("weeks"); + for (int i = 0; i < weeksJSON.length(); i++) { + Week currentWeek = new Week(); + JSONObject currentWeekJSON = weeksJSON.getJSONObject(i); + + currentWeek.setTotal(currentWeekJSON.getInt("total")); + currentWeek.setAverage(currentWeekJSON.getInt("average")); + try { + if (i == 0 ) { + currentWeek.setChange(0); + } else { + currentWeek.setChange(currentWeekJSON.getInt("change")); + } + } catch (JSONException e){ + AppLog.w(AppLog.T.STATS, "Cannot parse the change value in weeks section. Trying to understand the meaning: 42!!"); + // if i == 0 is the first week. if not it could mean infinity + String aProblematicValue = currentWeekJSON.get("change").toString(); + if (aProblematicValue.contains("infinity")) { + currentWeek.setChange(Integer.MAX_VALUE); + } else { + currentWeek.setChange(0); + } + } + + JSONArray daysJSON = currentWeekJSON.getJSONArray("days"); + for (int j = 0; j < daysJSON.length(); j++) { + Day currentDay = new Day(); + JSONObject dayJSON = daysJSON.getJSONObject(j); + currentDay.setCount(dayJSON.getInt("count")); + currentDay.setDay(dayJSON.getString("day")); + currentWeek.getDays().add(currentDay); + } + mWeeks.add(currentWeek); + } + } catch (JSONException e) { + AppLog.e(AppLog.T.STATS, "Cannot parse the Weeks section", e); + } + } + + public class Day implements Serializable { + private int mCount; + private String mDay; + + public String getDay() { + return mDay; + } + + public void setDay(String day) { + this.mDay = day; + } + + public int getCount() { + return mCount; + } + + public void setCount(int count) { + this.mCount = count; + } + } + + public class Week implements Serializable { + int mChange; + int mTotal; + int mAverage; + List<Day> mDays = new LinkedList<>(); + + public int getTotal() { + return mTotal; + } + + public void setTotal(int total) { + this.mTotal = total; + } + + public int getAverage() { + return mAverage; + } + + public void setAverage(int average) { + this.mAverage = average; + } + + public int getChange() { + return mChange; + } + + public void setChange(int change) { + this.mChange = change; + } + + public List<Day> getDays() { + return mDays; + } + + public void setDays(List<Day> days) { + this.mDays = days; + } + } + + public class Year implements Serializable { + private String mLabel; + private int mTotal; + private List<Month> mMonths; + + public List<Month> getMonths() { + return mMonths; + } + + public void setMonths(List<Month> months) { + mMonths = months; + } + + public String getLabel() { + return mLabel; + } + + public void setLabel(String label) { + this.mLabel = label; + } + + public int getTotal() { + return mTotal; + } + + public void setTotal(int total) { + this.mTotal = total; + } + } + + public class Month implements Serializable { + private final int mCount; + private final String mMonth; + + Month(String label, int count) { + this.mMonth = label; + this.mCount = count; + } + + public String getMonth() { + return mMonth; + } + public int getCount() { + return mCount; + } + } +}
\ No newline at end of file diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/models/PublicizeModel.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/PublicizeModel.java new file mode 100644 index 000000000..295a28061 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/PublicizeModel.java @@ -0,0 +1,40 @@ +package org.wordpress.android.ui.stats.models; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.List; + +public class PublicizeModel extends BaseStatsModel { + private String mBlogID; + private List<SingleItemModel> mServices; + + public PublicizeModel(String blogID, JSONObject response) throws JSONException { + this.mBlogID = blogID; + JSONArray services = response.getJSONArray("services"); + if (services.length() > 0) { + mServices = new ArrayList<>(services.length()); + for (int i = 0; i < services.length(); i++) { + JSONObject current = services.getJSONObject(i); + String serviceName = current.getString("service"); + int followers = current.getInt("followers"); + SingleItemModel currentItem = new SingleItemModel(blogID, null, null, serviceName, followers, null, null); + mServices.add(currentItem); + } + } + } + + public List<SingleItemModel> getServices() { + return mServices; + } + + public String getBlogId() { + return mBlogID; + } + + public void setBlogId(String blogId) { + this.mBlogID = blogId; + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/models/ReferrerGroupModel.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/ReferrerGroupModel.java new file mode 100644 index 000000000..e093c0006 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/ReferrerGroupModel.java @@ -0,0 +1,124 @@ +package org.wordpress.android.ui.stats.models; + +import android.text.TextUtils; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.wordpress.android.ui.stats.StatsUtils; +import org.wordpress.android.util.JSONUtils; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * A model to represent a referrer group stat + */ +public class ReferrerGroupModel implements Serializable { + private String mBlogId; + private long mDate; + + private String mGroupId; + private String mName; + private String mIcon; + private int mTotal; + private String mUrl; + private List<ReferrerResultModel> mResults; + + public transient boolean isRestCallInProgress = false; + public transient boolean isMarkedAsSpam = false; + + public ReferrerGroupModel(String blogId, String date, JSONObject groupJSON) throws JSONException { + setBlogId(blogId); + setDate(StatsUtils.toMs(date)); + + setGroupId(groupJSON.getString("group")); + setName(groupJSON.getString("name")); + setTotal(groupJSON.getInt("total")); + setIcon(JSONUtils.getString(groupJSON, "icon")); + + // if URL is set in the response there is one result only. + if (!TextUtils.isEmpty(JSONUtils.getString(groupJSON, "url"))) { + setUrl(JSONUtils.getString(groupJSON, "url")); + } + + // results is an array when there are results, otherwise it's an object. + JSONArray resultsArray = groupJSON.optJSONArray("results"); + if (resultsArray != null) { + mResults = new ArrayList<>(); + for (int i = 0; i < resultsArray.length(); i++) { + JSONObject currentResultJSON = resultsArray.getJSONObject(i); + ReferrerResultModel currentResultModel = new ReferrerResultModel(blogId, + date, currentResultJSON); + mResults.add(currentResultModel); + } + // Sort the results by views. + Collections.sort(mResults, new java.util.Comparator<ReferrerResultModel>() { + public int compare(ReferrerResultModel o1, ReferrerResultModel o2) { + // descending order + return o2.getViews() - o1.getViews(); + } + }); + } + } + + public String getBlogId() { + return mBlogId; + } + + private void setBlogId(String blogId) { + this.mBlogId = blogId; + } + + public long getDate() { + return mDate; + } + + private void setDate(long date) { + this.mDate = date; + } + + public String getGroupId() { + return mGroupId; + } + + private void setGroupId(String groupId) { + this.mGroupId = groupId; + } + + public String getName() { + return mName; + } + + private void setName(String name) { + this.mName = name; + } + + public int getTotal() { + return mTotal; + } + + private void setTotal(int total) { + this.mTotal = total; + } + + public String getUrl() { + return mUrl; + } + + private void setUrl(String url) { + this.mUrl = url; + } + + public String getIcon() { + return mIcon; + } + + private void setIcon(String icon) { + this.mIcon = icon; + } + + public List<ReferrerResultModel> getResults() { return mResults; } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/models/ReferrerResultModel.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/ReferrerResultModel.java new file mode 100644 index 000000000..a2cb1fdc4 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/ReferrerResultModel.java @@ -0,0 +1,116 @@ +package org.wordpress.android.ui.stats.models; + +import android.text.TextUtils; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.wordpress.android.ui.stats.StatsUtils; +import org.wordpress.android.util.JSONUtils; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * A model to represent a referrer result in stat + */ +public class ReferrerResultModel implements Serializable { + private String mBlogId; + private long mDate; + + private String mName; + private String mIcon; + private int mViews; + private String mUrl; + private List<SingleItemModel> mChildren; + + public ReferrerResultModel(String blogId, String date, JSONObject resultJSON) throws JSONException { + setBlogId(blogId); + setDate(StatsUtils.toMs(date)); + + setName(resultJSON.getString("name")); + setViews(resultJSON.getInt("views")); + setIcon(JSONUtils.getString(resultJSON, "icon")); + + if (!TextUtils.isEmpty(JSONUtils.getString(resultJSON, "url"))) { + setUrl(JSONUtils.getString(resultJSON, "url")); + } + + if (resultJSON.has("children")) { + JSONArray childrenJSON = resultJSON.getJSONArray("children"); + mChildren = new ArrayList<>(); + for (int i = 0; i < childrenJSON.length(); i++) { + JSONObject currentChild = childrenJSON.getJSONObject(i); + mChildren.add(getChildren(blogId, date, currentChild)); + } + + //Sort the children by views. + Collections.sort(mChildren, new java.util.Comparator<SingleItemModel>() { + public int compare(SingleItemModel o1, SingleItemModel o2) { + // descending order + return o2.getTotals() - o1.getTotals(); + } + }); + } + } + + private SingleItemModel getChildren(String blogId, String date, JSONObject child) throws JSONException { + String name = child.getString("name"); + int totals = child.getInt("views"); + String icon = JSONUtils.getString(child, "icon"); + String url = child.optString("url"); + return new SingleItemModel(blogId, date, null, name, totals, url, icon); + } + + public String getBlogId() { + return mBlogId; + } + + private void setBlogId(String blogId) { + this.mBlogId = blogId; + } + + public long getDate() { + return mDate; + } + + private void setDate(long date) { + this.mDate = date; + } + + public String getName() { + return mName; + } + + private void setName(String name) { + this.mName = name; + } + + public int getViews() { + return mViews; + } + + private void setViews(int total) { + this.mViews = total; + } + + public String getIcon() { + return mIcon; + } + + private void setIcon(String icon) { + this.mIcon = icon; + } + + public String getUrl() { + return mUrl; + } + + private void setUrl(String url) { + this.mUrl = url; + } + + public List<SingleItemModel> getChildren() { return mChildren; } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/models/ReferrersModel.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/ReferrersModel.java new file mode 100644 index 000000000..62f5f32fe --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/ReferrersModel.java @@ -0,0 +1,90 @@ +package org.wordpress.android.ui.stats.models; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.wordpress.android.util.AppLog; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + + +public class ReferrersModel extends BaseStatsModel { + private String mPeriod; + private String mDate; + private String mBlogID; + private int mOtherViews; + private int mTotalViews; + private List<ReferrerGroupModel> mGroups; + + public ReferrersModel(String blogID, JSONObject response) throws JSONException { + this.mBlogID = blogID; + this.mPeriod = response.getString("period"); + this.mDate = response.getString("date"); + + JSONObject jDaysObject = response.getJSONObject("days"); + if (jDaysObject.length() == 0) { + throw new JSONException("Invalid document returned from the REST API"); + } + + JSONArray jGroupsArray; + // Read the first day + Iterator<String> keys = jDaysObject.keys(); + String key = keys.next(); + JSONObject firstDayObject = jDaysObject.getJSONObject(key); + this.mOtherViews = firstDayObject.optInt("other_views"); + this.mTotalViews = firstDayObject.optInt("total_views"); + jGroupsArray = firstDayObject.optJSONArray("groups"); + + if (jGroupsArray != null) { + mGroups = new ArrayList<>(jGroupsArray.length()); + for (int i = 0; i < jGroupsArray.length(); i++) { + try { + JSONObject currentGroupJSON = jGroupsArray.getJSONObject(i); + ReferrerGroupModel currentGroupModel = new ReferrerGroupModel(blogID, mDate, currentGroupJSON); + mGroups.add(currentGroupModel); + } catch (JSONException e) { + AppLog.e(AppLog.T.STATS, "Unexpected ReferrerGroupModel object " + + "at position " + i + " Response: " + response.toString(), e); + } + } + } + } + + public String getBlogID() { + return mBlogID; + } + + public void setBlogID(String blogID) { + this.mBlogID = blogID; + } + + public String getDate() { + return mDate; + } + + public void setDate(String date) { + this.mDate = date; + } + + public String getPeriod() { + return mPeriod; + } + + public void setPeriod(String period) { + this.mPeriod = period; + } + + public List<ReferrerGroupModel> getGroups() { + return this.mGroups; + } + + public int getOtherViews() { + return mOtherViews; + } + + public int getTotalViews() { + return mTotalViews; + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/models/SearchTermModel.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/SearchTermModel.java new file mode 100644 index 000000000..b83b71b01 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/SearchTermModel.java @@ -0,0 +1,18 @@ +package org.wordpress.android.ui.stats.models; + +import java.io.Serializable; + +public class SearchTermModel extends SingleItemModel implements Serializable { + + private final boolean mIsEncriptedTerms; + + public SearchTermModel(String blogId, String date, String title, int totals, boolean isEncriptedTerms) { + super(blogId, date, null, title, totals, null, null); + this.mIsEncriptedTerms = isEncriptedTerms; + } + + public boolean isEncriptedTerms() { + return mIsEncriptedTerms; + } + +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/models/SearchTermsModel.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/SearchTermsModel.java new file mode 100644 index 000000000..49fde8f04 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/SearchTermsModel.java @@ -0,0 +1,108 @@ +package org.wordpress.android.ui.stats.models; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.wordpress.android.util.AppLog; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + + +public class SearchTermsModel extends BaseStatsModel { + private String mPeriod; + private String mDate; + private String mBlogID; + private List<SearchTermModel> mSearchTerms; + private int mEncryptedSearchTerms, mOtherSearchTerms, mTotalSearchTerms; + + public SearchTermsModel(String blogID, JSONObject response) throws JSONException { + this.mBlogID = blogID; + this.mPeriod = response.getString("period"); + this.mDate = response.getString("date"); + + JSONArray searchTermsArray = null; + JSONObject jDaysObject = response.getJSONObject("days"); + if (jDaysObject.length() == 0) { + throw new JSONException("Invalid document returned from the REST API"); + } + + Iterator<String> keys = jDaysObject.keys(); + if (keys.hasNext()) { + String key = keys.next(); + JSONObject jDateObject = jDaysObject.optJSONObject(key); // This could be an empty array on site with low traffic + searchTermsArray = null; + if (jDateObject != null) { + searchTermsArray = jDateObject.getJSONArray("search_terms"); + this.mEncryptedSearchTerms = jDateObject.optInt("encrypted_search_terms"); + this.mOtherSearchTerms = jDateObject.optInt("other_search_terms"); + this.mTotalSearchTerms = jDateObject.optInt("total_search_terms"); + } + } + + if (searchTermsArray == null) { + searchTermsArray = new JSONArray(); + } + + ArrayList<SearchTermModel> list = new ArrayList<>(searchTermsArray.length()); + for (int i=0; i < searchTermsArray.length(); i++) { + try { + JSONObject postObject = searchTermsArray.getJSONObject(i); + String term = postObject.getString("term"); + int total = postObject.getInt("views"); + SearchTermModel currentModel = new SearchTermModel(blogID, mDate, term, total, false); + list.add(currentModel); + } catch (JSONException e) { + AppLog.e(AppLog.T.STATS, "Unexpected SearchTerm object in searchterms array" + + "at position " + i + " Response: " + response.toString(), e); + } + } + + this.mSearchTerms = list; + } + + public String getBlogID() { + return mBlogID; + } + + public void setBlogID(String blogID) { + this.mBlogID = blogID; + } + + public String getDate() { + return mDate; + } + + public void setDate(String date) { + this.mDate = date; + } + + public String getPeriod() { + return mPeriod; + } + + public void setPeriod(String period) { + this.mPeriod = period; + } + + public List<SearchTermModel> getSearchTerms() { + return mSearchTerms; + } + + public boolean hasSearchTerms() { + return mSearchTerms != null && mSearchTerms.size() > 0; + } + + public int getEncryptedSearchTerms() { + return mEncryptedSearchTerms; + } + + public int getOtherSearchTerms() { + return mOtherSearchTerms; + } + + public int getTotalSearchTerms() { + return mTotalSearchTerms; + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/models/SingleItemModel.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/SingleItemModel.java new file mode 100644 index 000000000..0f3b2ef23 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/SingleItemModel.java @@ -0,0 +1,70 @@ +package org.wordpress.android.ui.stats.models; + +import android.webkit.URLUtil; + +import org.wordpress.android.ui.stats.StatsUtils; + +import java.io.Serializable; + +/* +* A model to represent a SINGLE stats item +*/ +public class SingleItemModel implements Serializable { + private final String mBlogID; + private final String mItemID; + private final long mDate; + private final String mTitle; + private final int mTotals; + private final String mUrl; + private final String mIcon; + + public SingleItemModel(String blogId, String date, String itemID, String title, int totals, String url, String icon) { + this(blogId, StatsUtils.toMs(date), itemID, title, totals, url, icon); + } + + SingleItemModel(String blogId, long date, String itemID, String title, int totals, String url, String icon) { + this.mBlogID = blogId; + this.mItemID = itemID; + this.mTitle = title; + this.mTotals = totals; + + // We could get invalid data back from the server. Check that URL is OK. + if (!URLUtil.isValidUrl(url)) { + this.mUrl = ""; + } else { + this.mUrl = url; + } + + this.mDate = date; + this.mIcon = icon; + } + + public String getBlogID() { + return mBlogID; + } + + public String getItemID() { + return mItemID; + } + + public String getTitle() { + return mTitle; + } + + public int getTotals() { + return mTotals; + } + + public String getUrl() { + return mUrl; + } + + public String getIcon() { + return mIcon; + } + + public long getDate() { + return mDate; + } + +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/models/TagModel.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/TagModel.java new file mode 100644 index 000000000..087c3db58 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/TagModel.java @@ -0,0 +1,31 @@ +package org.wordpress.android.ui.stats.models; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.Serializable; + +public class TagModel implements Serializable { + private String mName; + private String mLink; + private String mType; + + public TagModel(JSONObject tagJSON) throws JSONException { + + this.mName = tagJSON.getString("name"); + this.mType = tagJSON.getString("type"); + this.mLink = tagJSON.getString("link"); + } + + public String getName() { + return mName; + } + + public String getLink() { + return mLink; + } + + public String getType() { + return mType; + } +}
\ No newline at end of file diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/models/TagsContainerModel.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/TagsContainerModel.java new file mode 100644 index 000000000..4c35ccc1f --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/TagsContainerModel.java @@ -0,0 +1,43 @@ +package org.wordpress.android.ui.stats.models; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.List; + +public class TagsContainerModel extends BaseStatsModel { + private String mDate; + private String mBlogID; + private List<TagsModel> mTags; + + public TagsContainerModel(String blogID, JSONObject response) throws JSONException { + this.mBlogID = blogID; + this.mDate = response.getString("date"); + JSONArray outerTags = response.getJSONArray("tags"); + if (outerTags != null) { + mTags = new ArrayList<>(outerTags.length()); + for (int i = 0; i < outerTags.length(); i++) { + JSONObject current = outerTags.getJSONObject(i); + mTags.add(new TagsModel(current)); + } + } + } + + public List<TagsModel> getTags() { + return mTags; + } + + public String getBlogId() { + return mBlogID; + } + + public void setBlogId(String blogId) { + this.mBlogID = blogId; + } + + public String getDate() { + return mDate; + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/models/TagsModel.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/TagsModel.java new file mode 100644 index 000000000..19ce375ef --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/TagsModel.java @@ -0,0 +1,35 @@ +package org.wordpress.android.ui.stats.models; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +public class TagsModel implements Serializable { + private int mViews; + private List<TagModel> mTags; + + public TagsModel(JSONObject responseJSON) throws JSONException { + this.mViews = responseJSON.getInt("views"); + JSONArray innerTagsJSON = responseJSON.getJSONArray("tags"); + mTags = new ArrayList<>(innerTagsJSON.length()); + for (int i = 0; i < innerTagsJSON.length(); i++) { + JSONObject currentTagJSON = innerTagsJSON.getJSONObject(i); + TagModel currentTag = new TagModel(currentTagJSON); + mTags.add(currentTag); + } + } + + + + public List<TagModel> getTags() { + return mTags; + } + + public int getViews() { + return mViews; + } +}
\ No newline at end of file diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/models/TopPostsAndPagesModel.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/TopPostsAndPagesModel.java new file mode 100644 index 000000000..5ccf3bef4 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/TopPostsAndPagesModel.java @@ -0,0 +1,93 @@ +package org.wordpress.android.ui.stats.models; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.wordpress.android.util.AppLog; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + + +public class TopPostsAndPagesModel extends BaseStatsModel { + private String mPeriod; + private String mDate; + private String mBlogID; + private List<PostModel> mTopPostsAndPages; + + public TopPostsAndPagesModel(String blogID, JSONObject response) throws JSONException { + this.mBlogID = blogID; + this.mPeriod = response.getString("period"); + this.mDate = response.getString("date"); + JSONArray postViewsArray = null; + JSONObject jDaysObject = response.getJSONObject("days"); + if (jDaysObject.length() == 0) { + throw new JSONException("Invalid document returned from the REST API"); + } + + Iterator<String> keys = jDaysObject.keys(); + if (keys.hasNext()) { + String key = keys.next(); + JSONObject jDateObject = jDaysObject.optJSONObject(key); // This could be an empty array on site with low traffic + postViewsArray = (jDateObject != null) ? jDateObject.getJSONArray("postviews") : null; + } + + if (postViewsArray == null) { + postViewsArray = new JSONArray(); + } + + ArrayList<PostModel> list = new ArrayList<>(postViewsArray.length()); + + for (int i=0; i < postViewsArray.length(); i++) { + try { + JSONObject postObject = postViewsArray.getJSONObject(i); + String itemID = postObject.getString("id"); + String itemTitle = postObject.getString("title"); + int itemTotal = postObject.getInt("views"); + String itemURL = postObject.getString("href"); + String itemType = postObject.getString("type"); + String itemDate = postObject.getString("date"); + PostModel currentModel = new PostModel(blogID, itemDate, itemID, itemTitle, + itemTotal, itemURL, itemType); + list.add(currentModel); + } catch (JSONException e) { + AppLog.e(AppLog.T.STATS, "Unexpected PostModel object in top posts and pages array " + + "at position " + i + " Response: " + response.toString(), e); + } + } + this.mTopPostsAndPages = list; + } + + public String getBlogID() { + return mBlogID; + } + + public void setBlogID(String blogID) { + this.mBlogID = blogID; + } + + public String getDate() { + return mDate; + } + + public void setDate(String date) { + this.mDate = date; + } + + public String getPeriod() { + return mPeriod; + } + + public void setPeriod(String period) { + this.mPeriod = period; + } + + public List<PostModel> getTopPostsAndPages() { + return mTopPostsAndPages; + } + + public boolean hasTopPostsAndPages() { + return mTopPostsAndPages != null && mTopPostsAndPages.size() > 0; + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/models/VideoPlaysModel.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/VideoPlaysModel.java new file mode 100644 index 000000000..ef31b2f19 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/VideoPlaysModel.java @@ -0,0 +1,87 @@ +package org.wordpress.android.ui.stats.models; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + + +public class VideoPlaysModel extends BaseStatsModel { + private String mPeriod; + private String mDate; + private String mBlogID; + private int mOtherPlays; + private int mTotalPlays; + private List<SingleItemModel> mPlays; + + public VideoPlaysModel(String blogID, JSONObject response) throws JSONException { + this.mBlogID = blogID; + this.mPeriod = response.getString("period"); + this.mDate = response.getString("date"); + + JSONObject jDaysObject = response.getJSONObject("days"); + if (jDaysObject.length() == 0) { + throw new JSONException("Invalid document returned from the REST API"); + } + + // Read the first day + Iterator<String> keys = jDaysObject.keys(); + String key = keys.next(); + JSONObject firstDayObject = jDaysObject.getJSONObject(key); + this.mOtherPlays = firstDayObject.getInt("other_plays"); + this.mTotalPlays = firstDayObject.getInt("total_plays"); + JSONArray playsJSONArray = firstDayObject.optJSONArray("plays"); + + if (playsJSONArray != null) { + mPlays = new ArrayList<>(playsJSONArray.length()); + for (int i = 0; i < playsJSONArray.length(); i++) { + JSONObject currentVideoplaysJSON = playsJSONArray.getJSONObject(i); + String postId = String.valueOf(currentVideoplaysJSON.getInt("post_id")); + String title = currentVideoplaysJSON.getString("title"); + int views = currentVideoplaysJSON.getInt("plays"); + String url = currentVideoplaysJSON.getString("url"); + SingleItemModel currentPost = new SingleItemModel(blogID, mDate, postId, title, views, url, null); + mPlays.add(currentPost); + } + } + } + + public String getBlogID() { + return mBlogID; + } + + public void setBlogID(String blogID) { + this.mBlogID = blogID; + } + + public String getDate() { + return mDate; + } + + public void setDate(String date) { + this.mDate = date; + } + + public String getPeriod() { + return mPeriod; + } + + public void setPeriod(String period) { + this.mPeriod = period; + } + + public List<SingleItemModel> getPlays() { + return this.mPlays; + } + + public int getOtherPlays() { + return mOtherPlays; + } + + public int getTotalPlays() { + return mTotalPlays; + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/models/VisitModel.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/VisitModel.java new file mode 100644 index 000000000..d1f4ac613 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/VisitModel.java @@ -0,0 +1,62 @@ +package org.wordpress.android.ui.stats.models; + +import java.io.Serializable; + +public class VisitModel implements Serializable { + + private int mViews; + private int mLikes; + private int mVisitors; + private int mComments; + private String mPeriod; + private String mBlogID; + + public String getBlogID() { + return mBlogID; + } + + public void setBlogID(String blogID) { + this.mBlogID = blogID; + } + + public int getViews() { + return mViews; + } + + public void setViews(int views) { + this.mViews = views; + } + + public int getLikes() { + return mLikes; + } + + public void setLikes(int likes) { + this.mLikes = likes; + } + + public int getVisitors() { + return mVisitors; + } + + public void setVisitors(int visitors) { + this.mVisitors = visitors; + } + + public int getComments() { + return mComments; + } + + public void setComments(int comments) { + this.mComments = comments; + } + + public String getPeriod() { + return mPeriod; + } + + public void setPeriod(String period) { + this.mPeriod = period; + } + +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/models/VisitsModel.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/VisitsModel.java new file mode 100644 index 000000000..4b96bf3a3 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/models/VisitsModel.java @@ -0,0 +1,136 @@ +package org.wordpress.android.ui.stats.models; + +import android.text.TextUtils; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.StringUtils; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; + +public class VisitsModel extends BaseStatsModel { + private String mFields; // Holds a JSON Object + private String mUnit; + private String mDate; + private String mBlogID; + private List<VisitModel> mVisits; + + public VisitsModel(String blogID, JSONObject response) throws JSONException { + this.setBlogID(blogID); + this.setDate(response.getString("date")); + this.setUnit(response.getString("unit")); + this.setFields(response.getJSONArray("fields").toString()); + + JSONArray dataJSON; + try { + dataJSON = response.getJSONArray("data"); + } catch (JSONException e) { + AppLog.e(AppLog.T.STATS, this.getClass().getName() + " cannot convert the data field to a JSON array", e); + dataJSON = new JSONArray(); + } + + if (dataJSON == null || dataJSON.length() == 0) { + mVisits = new ArrayList<>(0); + } else { + // Read the position/index of each field in the response + HashMap<String, Integer> columnsMapping = new HashMap<>(6); + final JSONArray fieldsJSON = getFieldsJSON(); + if (fieldsJSON == null || fieldsJSON.length() == 0) { + mVisits = new ArrayList<>(0); + } else { + try { + for (int i = 0; i < fieldsJSON.length(); i++) { + final String field = fieldsJSON.getString(i); + columnsMapping.put(field, i); + } + } catch (JSONException e) { + AppLog.e(AppLog.T.STATS, "Cannot read the parameter fields from the JSON response." + + "Response: " + response.toString(), e); + mVisits = new ArrayList<>(0); + } + } + + int viewsColumnIndex = columnsMapping.get("views"); + int visitorsColumnIndex = columnsMapping.get("visitors"); + int likesColumnIndex = columnsMapping.get("likes"); + int commentsColumnIndex = columnsMapping.get("comments"); + int periodColumnIndex = columnsMapping.get("period"); + + int numPoints = dataJSON.length(); + mVisits = new ArrayList<>(numPoints); + + for (int i = 0; i < numPoints; i++) { + try { + JSONArray currentDayData = dataJSON.getJSONArray(i); + VisitModel currentVisitModel = new VisitModel(); + currentVisitModel.setBlogID(getBlogID()); + currentVisitModel.setPeriod(currentDayData.getString(periodColumnIndex)); + currentVisitModel.setViews(currentDayData.getInt(viewsColumnIndex)); + currentVisitModel.setVisitors(currentDayData.getInt(visitorsColumnIndex)); + currentVisitModel.setComments(currentDayData.getInt(commentsColumnIndex)); + currentVisitModel.setLikes(currentDayData.getInt(likesColumnIndex)); + mVisits.add(currentVisitModel); + } catch (JSONException e) { + AppLog.e(AppLog.T.STATS, "Cannot read the Visit item at index " + i + + " Response: " + response.toString(), e); + } + } + } + } + + public List<VisitModel> getVisits() { + return mVisits; + } + + public String getBlogID() { + return mBlogID; + } + + private void setBlogID(String blogID) { + this.mBlogID = blogID; + } + + public String getDate() { + return mDate; + } + + private void setDate(String date) { + this.mDate = date; + } + + public String getUnit() { + return mUnit; + } + + private void setUnit(String unit) { + this.mUnit = unit; + } + + private JSONArray getFieldsJSON() { + JSONArray jArray; + try { + String categories = StringUtils.unescapeHTML(this.getFields() != null ? this.getFields() : "[]"); + if (TextUtils.isEmpty(categories)) { + jArray = new JSONArray(); + } else { + jArray = new JSONArray(categories); + } + } catch (JSONException e) { + AppLog.e(AppLog.T.STATS, this.getClass().getName() + " cannot convert the string to JSON", e); + return null; + } + return jArray; + } + + private void setFields(String fields) { + this.mFields = fields; + } + + private String getFields() { + return mFields; + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/service/StatsService.java b/WordPress/src/main/java/org/wordpress/android/ui/stats/service/StatsService.java new file mode 100644 index 000000000..77e42c01a --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/service/StatsService.java @@ -0,0 +1,614 @@ +package org.wordpress.android.ui.stats.service; + +import android.app.Service; +import android.content.Intent; +import android.os.IBinder; +import android.text.TextUtils; + +import com.android.volley.Request; +import com.android.volley.VolleyError; +import com.wordpress.rest.RestRequest; + +import org.json.JSONException; +import org.json.JSONObject; +import org.wordpress.android.WordPress; +import org.wordpress.android.models.Blog; +import org.wordpress.android.networking.RestClientUtils; +import org.wordpress.android.ui.stats.StatsEvents; +import org.wordpress.android.ui.stats.StatsTimeframe; +import org.wordpress.android.ui.stats.StatsUtils; +import org.wordpress.android.ui.stats.StatsWidgetProvider; +import org.wordpress.android.ui.stats.datasets.StatsTable; +import org.wordpress.android.ui.stats.exceptions.StatsError; +import org.wordpress.android.ui.stats.models.AuthorsModel; +import org.wordpress.android.ui.stats.models.BaseStatsModel; +import org.wordpress.android.ui.stats.models.ClicksModel; +import org.wordpress.android.ui.stats.models.CommentFollowersModel; +import org.wordpress.android.ui.stats.models.CommentsModel; +import org.wordpress.android.ui.stats.models.FollowersModel; +import org.wordpress.android.ui.stats.models.GeoviewsModel; +import org.wordpress.android.ui.stats.models.InsightsAllTimeModel; +import org.wordpress.android.ui.stats.models.InsightsLatestPostDetailsModel; +import org.wordpress.android.ui.stats.models.InsightsLatestPostModel; +import org.wordpress.android.ui.stats.models.InsightsPopularModel; +import org.wordpress.android.ui.stats.models.PublicizeModel; +import org.wordpress.android.ui.stats.models.ReferrersModel; +import org.wordpress.android.ui.stats.models.SearchTermsModel; +import org.wordpress.android.ui.stats.models.TagsContainerModel; +import org.wordpress.android.ui.stats.models.TopPostsAndPagesModel; +import org.wordpress.android.ui.stats.models.VideoPlaysModel; +import org.wordpress.android.ui.stats.models.VisitModel; +import org.wordpress.android.ui.stats.models.VisitsModel; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; + +import java.io.Serializable; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadPoolExecutor; + +import de.greenrobot.event.EventBus; + +/** + * Background service to retrieve Stats. + * Parsing of response(s) and submission of new network calls are done by using a ThreadPoolExecutor with a single thread. + */ + +public class StatsService extends Service { + public static final String ARG_BLOG_ID = "blog_id"; + public static final String ARG_PERIOD = "stats_period"; + public static final String ARG_DATE = "stats_date"; + public static final String ARG_SECTION = "stats_section"; + public static final String ARG_MAX_RESULTS = "stats_max_results"; + public static final String ARG_PAGE_REQUESTED = "stats_page_requested"; + + private static final int DEFAULT_NUMBER_OF_RESULTS = 12; + // The number of results to return per page for Paged REST endpoints. Numbers larger than 20 will default to 20 on the server. + public static final int MAX_RESULTS_REQUESTED_PER_PAGE = 20; + + public enum StatsEndpointsEnum { + VISITS, + TOP_POSTS, + REFERRERS, + CLICKS, + GEO_VIEWS, + AUTHORS, + VIDEO_PLAYS, + COMMENTS, + FOLLOWERS_WPCOM, + FOLLOWERS_EMAIL, + COMMENT_FOLLOWERS, + TAGS_AND_CATEGORIES, + PUBLICIZE, + SEARCH_TERMS, + INSIGHTS_POPULAR, + INSIGHTS_ALL_TIME, + INSIGHTS_TODAY, + INSIGHTS_LATEST_POST_SUMMARY, + INSIGHTS_LATEST_POST_VIEWS; + + public String getRestEndpointPath() { + switch (this) { + case VISITS: + return "visits"; + case TOP_POSTS: + return "top-posts"; + case REFERRERS: + return "referrers"; + case CLICKS: + return "clicks"; + case GEO_VIEWS: + return "country-views"; + case AUTHORS: + return "top-authors"; + case VIDEO_PLAYS: + return "video-plays"; + case COMMENTS: + return "comments"; + case FOLLOWERS_WPCOM: + return "followers?type=wpcom"; + case FOLLOWERS_EMAIL: + return "followers?type=email"; + case COMMENT_FOLLOWERS: + return "comment-followers"; + case TAGS_AND_CATEGORIES: + return "tags"; + case PUBLICIZE: + return "publicize"; + case SEARCH_TERMS: + return "search-terms"; + case INSIGHTS_POPULAR: + return "insights"; + case INSIGHTS_ALL_TIME: + return ""; + case INSIGHTS_TODAY: + return "summary"; + case INSIGHTS_LATEST_POST_SUMMARY: + return "posts"; + case INSIGHTS_LATEST_POST_VIEWS: + return "post"; + default: + AppLog.i(T.STATS, "Called an update of Stats of unknown section!?? " + this.name()); + return ""; + } + } + + public StatsEvents.SectionUpdatedAbstract getEndpointUpdateEvent(final String blogId, final StatsTimeframe timeframe, final String date, + final int maxResultsRequested, final int pageRequested, final BaseStatsModel data) { + switch (this) { + case VISITS: + return new StatsEvents.VisitorsAndViewsUpdated(blogId, timeframe, date, + maxResultsRequested, pageRequested, (VisitsModel)data); + case TOP_POSTS: + return new StatsEvents.TopPostsUpdated(blogId, timeframe, date, + maxResultsRequested, pageRequested, (TopPostsAndPagesModel)data); + case REFERRERS: + return new StatsEvents.ReferrersUpdated(blogId, timeframe, date, + maxResultsRequested, pageRequested, (ReferrersModel)data); + case CLICKS: + return new StatsEvents.ClicksUpdated(blogId, timeframe, date, + maxResultsRequested, pageRequested, (ClicksModel)data); + case AUTHORS: + return new StatsEvents.AuthorsUpdated(blogId, timeframe, date, + maxResultsRequested, pageRequested, (AuthorsModel)data); + case GEO_VIEWS: + return new StatsEvents.CountriesUpdated(blogId, timeframe, date, + maxResultsRequested, pageRequested, (GeoviewsModel)data); + case VIDEO_PLAYS: + return new StatsEvents.VideoPlaysUpdated(blogId, timeframe, date, + maxResultsRequested, pageRequested, (VideoPlaysModel)data); + case SEARCH_TERMS: + return new StatsEvents.SearchTermsUpdated(blogId, timeframe, date, + maxResultsRequested, pageRequested, (SearchTermsModel)data); + case COMMENTS: + return new StatsEvents.CommentsUpdated(blogId, timeframe, date, + maxResultsRequested, pageRequested, (CommentsModel)data); + case COMMENT_FOLLOWERS: + return new StatsEvents.CommentFollowersUpdated(blogId, timeframe, date, + maxResultsRequested, pageRequested, (CommentFollowersModel)data); + case TAGS_AND_CATEGORIES: + return new StatsEvents.TagsUpdated(blogId, timeframe, date, + maxResultsRequested, pageRequested, (TagsContainerModel)data); + case PUBLICIZE: + return new StatsEvents.PublicizeUpdated(blogId, timeframe, date, + maxResultsRequested, pageRequested, (PublicizeModel)data); + case FOLLOWERS_WPCOM: + return new StatsEvents.FollowersWPCOMUdated(blogId, timeframe, date, + maxResultsRequested, pageRequested, (FollowersModel)data); + case FOLLOWERS_EMAIL: + return new StatsEvents.FollowersEmailUdated(blogId, timeframe, date, + maxResultsRequested, pageRequested, (FollowersModel)data); + case INSIGHTS_POPULAR: + return new StatsEvents.InsightsPopularUpdated(blogId, timeframe, date, + maxResultsRequested, pageRequested, (InsightsPopularModel)data); + case INSIGHTS_ALL_TIME: + return new StatsEvents.InsightsAllTimeUpdated(blogId, timeframe, date, + maxResultsRequested, pageRequested, (InsightsAllTimeModel)data); + case INSIGHTS_TODAY: + return new StatsEvents.VisitorsAndViewsUpdated(blogId, timeframe, date, + maxResultsRequested, pageRequested, (VisitsModel)data); + case INSIGHTS_LATEST_POST_SUMMARY: + return new StatsEvents.InsightsLatestPostSummaryUpdated(blogId, timeframe, date, + maxResultsRequested, pageRequested, (InsightsLatestPostModel)data); + case INSIGHTS_LATEST_POST_VIEWS: + return new StatsEvents.InsightsLatestPostDetailsUpdated(blogId, timeframe, date, + maxResultsRequested, pageRequested, (InsightsLatestPostDetailsModel)data); + default: + AppLog.e(T.STATS, "Can't find an Update Event that match the current endpoint: " + this.name()); + } + + return null; + } + } + + private int mServiceStartId; + private final LinkedList<Request<JSONObject>> mStatsNetworkRequests = new LinkedList<>(); + private final ThreadPoolExecutor singleThreadNetworkHandler = (ThreadPoolExecutor) Executors.newFixedThreadPool(1); + + @Override + public void onCreate() { + super.onCreate(); + AppLog.i(T.STATS, "service created"); + } + + @Override + public void onDestroy() { + stopRefresh(); + AppLog.i(T.STATS, "service destroyed"); + super.onDestroy(); + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent == null) { + AppLog.e(T.STATS, "StatsService was killed and restarted with a null intent."); + // if this service's process is killed while it is started (after returning from onStartCommand(Intent, int, int)), + // then leave it in the started state but don't retain this delivered intent. + // Later the system will try to re-create the service. + // Because it is in the started state, it will guarantee to call onStartCommand(Intent, int, int) after creating the new service instance; + // if there are not any pending start commands to be delivered to the service, it will be called with a null intent object. + stopRefresh(); + return START_NOT_STICKY; + } + + final String blogId = intent.getStringExtra(ARG_BLOG_ID); + if (TextUtils.isEmpty(blogId)) { + AppLog.e(T.STATS, "StatsService was started with a blank blog_id "); + return START_NOT_STICKY; + } + + final StatsTimeframe period; + if (intent.hasExtra(ARG_PERIOD)) { + period = (StatsTimeframe) intent.getSerializableExtra(ARG_PERIOD); + } else { + period = StatsTimeframe.DAY; + } + + final String requestedDate; + if (intent.getStringExtra(ARG_DATE) == null) { + AppLog.w(T.STATS, "StatsService is started with a NULL date on this blogID - " + + blogId + ". Using current date!!!"); + int parsedBlogID = Integer.parseInt(blogId); + int localTableBlogId = WordPress.wpDB.getLocalTableBlogIdForRemoteBlogId(parsedBlogID); + requestedDate = StatsUtils.getCurrentDateTZ(localTableBlogId); + } else { + requestedDate = intent.getStringExtra(ARG_DATE); + } + + final int maxResultsRequested = intent.getIntExtra(ARG_MAX_RESULTS, DEFAULT_NUMBER_OF_RESULTS); + final int pageRequested = intent.getIntExtra(ARG_PAGE_REQUESTED, -1); + + int[] sectionFromIntent = intent.getIntArrayExtra(ARG_SECTION); + + this.mServiceStartId = startId; + for (int i=0; i < sectionFromIntent.length; i++){ + final StatsEndpointsEnum currentSectionsToUpdate = StatsEndpointsEnum.values()[sectionFromIntent[i]]; + singleThreadNetworkHandler.submit(new Thread() { + @Override + public void run() { + startTasks(blogId, period, requestedDate, currentSectionsToUpdate, maxResultsRequested, pageRequested); + } + }); + } + + return START_NOT_STICKY; + } + + private void stopRefresh() { + synchronized (mStatsNetworkRequests) { + this.mServiceStartId = 0; + for (Request<JSONObject> req : mStatsNetworkRequests) { + if (req != null && !req.hasHadResponseDelivered() && !req.isCanceled()) { + req.cancel(); + } + } + mStatsNetworkRequests.clear(); + } + } + + // A fast way to disable caching during develop or when we want to disable it + // under some circumstances. Always true for now. + private boolean isCacheEnabled() { + return true; + } + + // Check if we already have Stats + private String getCachedStats(final String blogId, final StatsTimeframe timeframe, final String date, final StatsEndpointsEnum sectionToUpdate, + final int maxResultsRequested, final int pageRequested) { + if (!isCacheEnabled()) { + return null; + } + + int parsedBlogID = Integer.parseInt(blogId); + int localTableBlogId = WordPress.wpDB.getLocalTableBlogIdForRemoteBlogId(parsedBlogID); + return StatsTable.getStats(this, localTableBlogId, timeframe, date, sectionToUpdate, maxResultsRequested, pageRequested); + } + + private void startTasks(final String blogId, final StatsTimeframe timeframe, final String date, final StatsEndpointsEnum sectionToUpdate, + final int maxResultsRequested, final int pageRequested) { + + EventBus.getDefault().post(new StatsEvents.UpdateStatusChanged(true)); + + String cachedStats = getCachedStats(blogId, timeframe, date, sectionToUpdate, maxResultsRequested, pageRequested); + if (cachedStats != null) { + BaseStatsModel mResponseObjectModel; + try { + JSONObject response = new JSONObject(cachedStats); + mResponseObjectModel = StatsUtils.parseResponse(sectionToUpdate, blogId, response); + + EventBus.getDefault().post( + sectionToUpdate.getEndpointUpdateEvent(blogId, timeframe, date, + maxResultsRequested, pageRequested, mResponseObjectModel) + ); + + updateWidgetsUI(blogId, sectionToUpdate, timeframe, date, pageRequested, mResponseObjectModel); + checkAllRequestsFinished(null); + return; + } catch (JSONException e) { + AppLog.e(AppLog.T.STATS, e); + } + } + + final RestClientUtils restClientUtils = WordPress.getRestClientUtilsV1_1(); + + String period = timeframe.getLabelForRestCall(); + /*AppLog.i(T.STATS, "A new Stats network request is required for blogID: " + blogId + " - period: " + period + + " - date: " + date + " - StatsType: " + sectionToUpdate.name()); +*/ + + + RestListener vListener = new RestListener(sectionToUpdate, blogId, timeframe, date, maxResultsRequested, pageRequested); + + final String periodDateMaxPlaceholder = "?period=%s&date=%s&max=%s"; + + String path = String.format("/sites/%s/stats/" + sectionToUpdate.getRestEndpointPath(), blogId); + synchronized (mStatsNetworkRequests) { + switch (sectionToUpdate) { + case VISITS: + path = String.format(path + "?unit=%s&quantity=15&date=%s", period, date); + break; + case TOP_POSTS: + case REFERRERS: + case CLICKS: + case GEO_VIEWS: + case AUTHORS: + case VIDEO_PLAYS: + case SEARCH_TERMS: + path = String.format(path + periodDateMaxPlaceholder, period, date, maxResultsRequested); + break; + case TAGS_AND_CATEGORIES: + case PUBLICIZE: + path = String.format(path + "?max=%s", maxResultsRequested); + break; + case COMMENTS: + // No parameters + break; + case FOLLOWERS_WPCOM: + if (pageRequested < 1) { + path = String.format(path + "&max=%s", maxResultsRequested); + } else { + path = String.format(path + "&period=%s&date=%s&max=%s&page=%s", + period, date, maxResultsRequested, pageRequested); + } + break; + case FOLLOWERS_EMAIL: + if (pageRequested < 1) { + path = String.format(path + "&max=%s", maxResultsRequested); + } else { + path = String.format(path + "&period=%s&date=%s&max=%s&page=%s", + period, date, maxResultsRequested, pageRequested); + } + break; + case COMMENT_FOLLOWERS: + if (pageRequested < 1) { + path = String.format(path + "?max=%s", maxResultsRequested); + } else { + path = String.format(path + "?period=%s&date=%s&max=%s&page=%s", period, + date, maxResultsRequested, pageRequested); + } + break; + case INSIGHTS_ALL_TIME: + case INSIGHTS_POPULAR: + break; + case INSIGHTS_TODAY: + path = String.format(path + "?period=day&date=%s", date); + break; + case INSIGHTS_LATEST_POST_SUMMARY: + // This is an edge cases since we're not loading stats but posts + path = String.format("/sites/%s/%s", blogId, sectionToUpdate.getRestEndpointPath() + + "?order_by=date&number=1&type=post&fields=ID,title,URL,discussion,like_count,date"); + break; + case INSIGHTS_LATEST_POST_VIEWS: + // This is a kind of edge case, since we used the pageRequested parameter to request a single postID + path = String.format(path + "/%s?fields=views", pageRequested); + break; + default: + AppLog.i(T.STATS, "Called an update of Stats of unknown section!?? " + sectionToUpdate.name()); + return; + } + + // We need to check if we already have the same request in the queue + if (checkIfRequestShouldBeEnqueued(restClientUtils, path)) { + AppLog.d(AppLog.T.STATS, "Enqueuing the following Stats request " + path); + Request<JSONObject> currentRequest = restClientUtils.get(path, vListener, vListener); + vListener.currentRequest = currentRequest; + currentRequest.setTag("StatsCall"); + mStatsNetworkRequests.add(currentRequest); + } else { + AppLog.d(AppLog.T.STATS, "Stats request is already in the queue:" + path); + } + } + } + + /** + * This method checks if we already have the same request in the Queue. No need to re-enqueue a new request + * if one with the same parameters is there. + * + * This method is a kind of tricky, since it does the comparison by checking the origin URL of requests. + * To do that we had to get the fullURL of the new request by calling a method of the REST client `getAbsoluteURL`. + * That's good for now, but could lead to errors if the RestClient changes the way the URL is constructed internally, + * by calling `getAbsoluteURL`. + * + * - Another approach would involve the get of the requests ErrorListener and the check Listener's parameters. + * - Cleanest approach is for sure to create a new class that extends Request<JSONObject> and stores parameters for later comparison, + * unfortunately we have to change the REST Client and RestClientUtils a lot if we want follow this way... + * + */ + private boolean checkIfRequestShouldBeEnqueued(final RestClientUtils restClientUtils, String path) { + String absoluteRequestPath = restClientUtils.getRestClient().getAbsoluteURL(path); + Iterator<Request<JSONObject>> it = mStatsNetworkRequests.iterator(); + while (it.hasNext()) { + Request<JSONObject> req = it.next(); + if (!req.hasHadResponseDelivered() && !req.isCanceled() && + absoluteRequestPath.equals(req.getOriginUrl())) { + return false; + } + } + + return true; + } + + // Call an updates on the installed widgets if the blog is the primary, the endpoint is Visits + // the timeframe is DAY or INSIGHTS, and the date = TODAY + private void updateWidgetsUI(String blogId, final StatsEndpointsEnum endpointName, + StatsTimeframe timeframe, String date, int pageRequested, + Serializable responseObjectModel) { + if (pageRequested != -1) { + return; + } + if (endpointName != StatsEndpointsEnum.VISITS) { + return; + } + if (timeframe != StatsTimeframe.DAY && timeframe != StatsTimeframe.INSIGHTS) { + return; + } + + int parsedBlogID = Integer.parseInt(blogId); + int localTableBlogId = WordPress.wpDB.getLocalTableBlogIdForRemoteBlogId(parsedBlogID); + // make sure the data is for the current date + if (!date.equals(StatsUtils.getCurrentDateTZ(localTableBlogId))) { + return; + } + + if (responseObjectModel == null) { + // TODO What we want to do here? + return; + } + + if (!StatsWidgetProvider.isBlogDisplayedInWidget(parsedBlogID)) { + AppLog.d(AppLog.T.STATS, "The blog with remoteID " + parsedBlogID + " is NOT displayed in any widget. Stats Service doesn't call an update of the widget."); + return; + } + + if (responseObjectModel instanceof VisitsModel) { + VisitsModel visitsModel = (VisitsModel) responseObjectModel; + if (visitsModel.getVisits() == null || visitsModel.getVisits().size() == 0) { + return; + } + List<VisitModel> visits = visitsModel.getVisits(); + VisitModel data = visits.get(visits.size() - 1); + StatsWidgetProvider.updateWidgets(getApplicationContext(), parsedBlogID, data); + } else if (responseObjectModel instanceof VolleyError) { + VolleyError error = (VolleyError) responseObjectModel; + StatsWidgetProvider.updateWidgets(getApplicationContext(), parsedBlogID, error); + } else if (responseObjectModel instanceof StatsError) { + StatsError statsError = (StatsError) responseObjectModel; + StatsWidgetProvider.updateWidgets(getApplicationContext(), parsedBlogID, statsError); + } + } + + private class RestListener implements RestRequest.Listener, RestRequest.ErrorListener { + final String mRequestBlogId; + private final StatsTimeframe mTimeframe; + final StatsEndpointsEnum mEndpointName; + private final String mDate; + private Request<JSONObject> currentRequest; + private final int mMaxResultsRequested, mPageRequested; + + public RestListener(StatsEndpointsEnum endpointName, String blogId, StatsTimeframe timeframe, String date, + final int maxResultsRequested, final int pageRequested) { + mRequestBlogId = blogId; + mTimeframe = timeframe; + mEndpointName = endpointName; + mDate = date; + mMaxResultsRequested = maxResultsRequested; + mPageRequested = pageRequested; + } + + @Override + public void onResponse(final JSONObject response) { + singleThreadNetworkHandler.submit(new Thread() { + @Override + public void run() { + // do other stuff here + BaseStatsModel mResponseObjectModel = null; + if (response != null) { + try { + //AppLog.d(T.STATS, response.toString()); + mResponseObjectModel = StatsUtils.parseResponse(mEndpointName, mRequestBlogId, response); + if (isCacheEnabled()) { + int parsedBlogID = Integer.parseInt(mRequestBlogId); + int localTableBlogId = WordPress.wpDB.getLocalTableBlogIdForRemoteBlogId(parsedBlogID); + StatsTable.insertStats(StatsService.this, localTableBlogId, mTimeframe, mDate, mEndpointName, + mMaxResultsRequested, mPageRequested, + response.toString(), System.currentTimeMillis()); + } + } catch (JSONException e) { + AppLog.e(AppLog.T.STATS, e); + } + } + + EventBus.getDefault().post( + mEndpointName.getEndpointUpdateEvent(mRequestBlogId, mTimeframe, mDate, + mMaxResultsRequested, mPageRequested, mResponseObjectModel) + ); + + updateWidgetsUI(mRequestBlogId, mEndpointName, mTimeframe, mDate, mPageRequested, mResponseObjectModel); + checkAllRequestsFinished(currentRequest); + } + }); + } + + @Override + public void onErrorResponse(final VolleyError volleyError) { + singleThreadNetworkHandler.submit(new Thread() { + @Override + public void run() { + AppLog.e(T.STATS, "Error while loading Stats!"); + StatsUtils.logVolleyErrorDetails(volleyError); + BaseStatsModel mResponseObjectModel = null; + // Check here if this is an authentication error + // .com authentication errors are handled automatically by the app + if (volleyError instanceof com.android.volley.AuthFailureError) { + int localId = StatsUtils.getLocalBlogIdFromRemoteBlogId( + Integer.parseInt(mRequestBlogId) + ); + Blog blog = WordPress.wpDB.instantiateBlogByLocalId(localId); + if (blog != null && blog.isJetpackPowered()) { + // It's a kind of edge case, but the Jetpack site could have REST Disabled + // In that case (only used in insights for now) shows the error in the module that use the REST API + if (!StatsUtils.isRESTDisabledError(volleyError)) { + EventBus.getDefault().post(new StatsEvents.JetpackAuthError(localId)); + } + } + } + + + EventBus.getDefault().post(new StatsEvents.SectionUpdateError(mEndpointName, mRequestBlogId, mTimeframe, mDate, + mMaxResultsRequested, mPageRequested, volleyError)); + + updateWidgetsUI(mRequestBlogId, mEndpointName, mTimeframe, mDate, mPageRequested, mResponseObjectModel); + checkAllRequestsFinished(currentRequest); + } + }); + } + } + + private void stopService() { + /* Stop the service if this is the current response, or mServiceBlogId is null + String currentServiceBlogId = getServiceBlogId(); + if (currentServiceBlogId == null || currentServiceBlogId.equals(mRequestBlogId)) { + stopService(); + }*/ + EventBus.getDefault().post(new StatsEvents.UpdateStatusChanged(false)); + stopSelf(mServiceStartId); + } + + + private void checkAllRequestsFinished(Request<JSONObject> req) { + synchronized (mStatsNetworkRequests) { + if (req != null) { + mStatsNetworkRequests.remove(req); + } + boolean isStillWorking = mStatsNetworkRequests.size() > 0 || singleThreadNetworkHandler.getQueue().size() > 0; + EventBus.getDefault().post(new StatsEvents.UpdateStatusChanged(isStillWorking)); + } + } +}
\ No newline at end of file |