1 /* 2 * Copyright (C) 2017 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License 15 */ 16 17 package com.android.dialer.main.impl; 18 19 import android.app.Fragment; 20 import android.app.FragmentTransaction; 21 import android.content.ActivityNotFoundException; 22 import android.content.Intent; 23 import android.os.Bundle; 24 import android.speech.RecognizerIntent; 25 import android.support.annotation.Nullable; 26 import android.support.design.widget.FloatingActionButton; 27 import android.support.v7.app.AppCompatActivity; 28 import android.text.TextUtils; 29 import android.view.MenuItem; 30 import android.view.View; 31 import android.view.animation.Animation; 32 import android.view.animation.Animation.AnimationListener; 33 import android.widget.Toast; 34 import com.android.contacts.common.dialog.ClearFrequentsDialog; 35 import com.android.dialer.app.calllog.CallLogActivity; 36 import com.android.dialer.app.settings.DialerSettingsActivity; 37 import com.android.dialer.callintent.CallInitiationType; 38 import com.android.dialer.common.Assert; 39 import com.android.dialer.common.LogUtil; 40 import com.android.dialer.constants.ActivityRequestCodes; 41 import com.android.dialer.dialpadview.DialpadFragment; 42 import com.android.dialer.dialpadview.DialpadFragment.DialpadListener; 43 import com.android.dialer.dialpadview.DialpadFragment.OnDialpadQueryChangedListener; 44 import com.android.dialer.logging.DialerImpression; 45 import com.android.dialer.logging.Logger; 46 import com.android.dialer.logging.ScreenEvent; 47 import com.android.dialer.main.impl.bottomnav.BottomNavBar; 48 import com.android.dialer.main.impl.toolbar.MainToolbar; 49 import com.android.dialer.main.impl.toolbar.SearchBarListener; 50 import com.android.dialer.searchfragment.list.NewSearchFragment; 51 import com.android.dialer.searchfragment.list.NewSearchFragment.SearchFragmentListener; 52 import com.android.dialer.smartdial.util.SmartDialNameMatcher; 53 import com.google.common.base.Optional; 54 import java.util.ArrayList; 55 import java.util.List; 56 57 /** 58 * Search controller for handling all the logic related to entering and exiting the search UI. 59 * 60 * <p>Components modified are: 61 * 62 * <ul> 63 * <li>Bottom Nav Bar, completely hidden when in search ui. 64 * <li>FAB, visible in dialpad search when dialpad is hidden. Otherwise, FAB is hidden. 65 * <li>Toolbar, expanded and visible when dialpad is hidden. Otherwise, hidden off screen. 66 * <li>Dialpad, shown through fab clicks and hidden with Android back button. 67 * </ul> 68 * 69 * @see #onBackPressed() 70 */ 71 public class MainSearchController implements SearchBarListener { 72 73 private static final String KEY_IS_FAB_HIDDEN = "is_fab_hidden"; 74 private static final String KEY_TOOLBAR_SHADOW_VISIBILITY = "toolbar_shadow_visibility"; 75 private static final String KEY_IS_TOOLBAR_EXPANDED = "is_toolbar_expanded"; 76 private static final String KEY_IS_TOOLBAR_SLIDE_UP = "is_toolbar_slide_up"; 77 78 private static final String DIALPAD_FRAGMENT_TAG = "dialpad_fragment_tag"; 79 private static final String SEARCH_FRAGMENT_TAG = "search_fragment_tag"; 80 81 private final MainActivity mainActivity; 82 private final BottomNavBar bottomNav; 83 private final FloatingActionButton fab; 84 private final MainToolbar toolbar; 85 private final View toolbarShadow; 86 87 private final List<OnSearchShowListener> onSearchShowListenerList = new ArrayList<>(); 88 89 public MainSearchController( 90 MainActivity mainActivity, 91 BottomNavBar bottomNav, 92 FloatingActionButton fab, 93 MainToolbar toolbar, 94 View toolbarShadow) { 95 this.mainActivity = mainActivity; 96 this.bottomNav = bottomNav; 97 this.fab = fab; 98 this.toolbar = toolbar; 99 this.toolbarShadow = toolbarShadow; 100 } 101 102 /** Should be called if we're showing the dialpad because of a new ACTION_DIAL intent. */ 103 public void showDialpadFromNewIntent() { 104 LogUtil.enterBlock("MainSearchController.showDialpadFromNewIntent"); 105 showDialpad(/* animate=*/ false, /* fromNewIntent=*/ true); 106 } 107 108 /** Shows the dialpad, hides the FAB and slides the toolbar off screen. */ 109 public void showDialpad(boolean animate) { 110 LogUtil.enterBlock("MainSearchController.showDialpad"); 111 showDialpad(animate, false); 112 } 113 114 private void showDialpad(boolean animate, boolean fromNewIntent) { 115 Assert.checkArgument(!isDialpadVisible()); 116 117 fab.hide(); 118 toolbar.slideUp(animate); 119 toolbar.expand(animate, Optional.absent()); 120 toolbarShadow.setVisibility(View.VISIBLE); 121 mainActivity.setTitle(R.string.dialpad_activity_title); 122 123 FragmentTransaction transaction = mainActivity.getFragmentManager().beginTransaction(); 124 NewSearchFragment searchFragment = getSearchFragment(); 125 126 // Show Search 127 if (searchFragment == null) { 128 // TODO(a bug): zero suggest results aren't actually shown but this enabled the nearby 129 // places promo to be shown. 130 searchFragment = NewSearchFragment.newInstance(/* showZeroSuggest=*/ true); 131 transaction.replace(R.id.fragment_container, searchFragment, SEARCH_FRAGMENT_TAG); 132 transaction.addToBackStack(null); 133 transaction.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE); 134 } else if (!isSearchVisible()) { 135 transaction.show(searchFragment); 136 } 137 searchFragment.setQuery("", CallInitiationType.Type.DIALPAD); 138 139 // Split the transactions so that the dialpad fragment isn't popped off the stack when we exit 140 // search. We do this so that the dialpad actually animates down instead of just disappearing. 141 transaction.commit(); 142 transaction = mainActivity.getFragmentManager().beginTransaction(); 143 144 // Show Dialpad 145 if (getDialpadFragment() == null) { 146 DialpadFragment dialpadFragment = new DialpadFragment(); 147 dialpadFragment.setStartedFromNewIntent(fromNewIntent); 148 transaction.add(R.id.dialpad_fragment_container, dialpadFragment, DIALPAD_FRAGMENT_TAG); 149 } else { 150 DialpadFragment dialpadFragment = getDialpadFragment(); 151 dialpadFragment.setStartedFromNewIntent(fromNewIntent); 152 transaction.show(dialpadFragment); 153 } 154 transaction.commit(); 155 156 notifyListenersOnSearchOpen(); 157 } 158 159 /** 160 * Hides the dialpad, reveals the FAB and slides the toolbar back onto the screen. 161 * 162 * <p>This method intentionally "hides" and does not "remove" the dialpad in order to preserve its 163 * state (i.e. we call {@link FragmentTransaction#hide(Fragment)} instead of {@link 164 * FragmentTransaction#remove(Fragment)}. 165 * 166 * @see {@link #closeSearch(boolean)} to "remove" the dialpad. 167 */ 168 private void hideDialpad(boolean animate, boolean bottomNavVisible) { 169 LogUtil.enterBlock("MainSearchController.hideDialpad"); 170 Assert.checkArgument(isDialpadVisible()); 171 172 fab.show(); 173 toolbar.slideDown(animate); 174 toolbar.transferQueryFromDialpad(getDialpadFragment().getQuery()); 175 mainActivity.setTitle(R.string.main_activity_label); 176 177 DialpadFragment dialpadFragment = getDialpadFragment(); 178 dialpadFragment.setAnimate(animate); 179 dialpadFragment.slideDown( 180 animate, 181 new AnimationListener() { 182 @Override 183 public void onAnimationStart(Animation animation) { 184 // Slide the bottom nav on animation start so it's (not) visible when the dialpad 185 // finishes animating down. 186 if (bottomNavVisible) { 187 showBottomNav(); 188 } else { 189 hideBottomNav(); 190 } 191 } 192 193 @Override 194 public void onAnimationEnd(Animation animation) { 195 if (!(mainActivity.isFinishing() || mainActivity.isDestroyed())) { 196 mainActivity.getFragmentManager().beginTransaction().hide(dialpadFragment).commit(); 197 } 198 } 199 200 @Override 201 public void onAnimationRepeat(Animation animation) {} 202 }); 203 } 204 205 private void hideBottomNav() { 206 bottomNav.setVisibility(View.GONE); 207 } 208 209 private void showBottomNav() { 210 bottomNav.setVisibility(View.VISIBLE); 211 } 212 213 /** Should be called when {@link DialpadListener#onDialpadShown()} is called. */ 214 public void onDialpadShown() { 215 LogUtil.enterBlock("MainSearchController.onDialpadShown"); 216 getDialpadFragment().slideUp(true); 217 hideBottomNav(); 218 } 219 220 /** 221 * @see SearchFragmentListener#onSearchListTouch() 222 * <p>There are 4 scenarios we support to provide a nice UX experience: 223 * <ol> 224 * <li>When the dialpad is visible with an empty query, close the search UI. 225 * <li>When the dialpad is visible with a non-empty query, hide the dialpad. 226 * <li>When the regular search UI is visible with an empty query, close the search UI. 227 * <li>When the regular search UI is visible with a non-empty query, hide the keyboard. 228 * </ol> 229 */ 230 public void onSearchListTouch() { 231 LogUtil.enterBlock("MainSearchController.onSearchListTouched"); 232 if (isDialpadVisible()) { 233 if (TextUtils.isEmpty(getDialpadFragment().getQuery())) { 234 Logger.get(mainActivity) 235 .logImpression( 236 DialerImpression.Type.MAIN_TOUCH_DIALPAD_SEARCH_LIST_TO_CLOSE_SEARCH_AND_DIALPAD); 237 closeSearch(true); 238 } else { 239 Logger.get(mainActivity) 240 .logImpression(DialerImpression.Type.MAIN_TOUCH_DIALPAD_SEARCH_LIST_TO_HIDE_DIALPAD); 241 hideDialpad(/* animate=*/ true, /* bottomNavVisible=*/ false); 242 } 243 } else if (isSearchVisible()) { 244 if (TextUtils.isEmpty(toolbar.getQuery())) { 245 Logger.get(mainActivity) 246 .logImpression(DialerImpression.Type.MAIN_TOUCH_SEARCH_LIST_TO_CLOSE_SEARCH); 247 closeSearch(true); 248 } else { 249 Logger.get(mainActivity) 250 .logImpression(DialerImpression.Type.MAIN_TOUCH_SEARCH_LIST_TO_HIDE_KEYBOARD); 251 toolbar.hideKeyboard(); 252 } 253 } 254 } 255 256 /** 257 * Should be called when the user presses the back button. 258 * 259 * @return true if #onBackPressed() handled to action. 260 */ 261 public boolean onBackPressed() { 262 if (isDialpadVisible() && !TextUtils.isEmpty(getDialpadFragment().getQuery())) { 263 LogUtil.i("MainSearchController.onBackPressed", "Dialpad visible with query"); 264 Logger.get(mainActivity) 265 .logImpression(DialerImpression.Type.MAIN_PRESS_BACK_BUTTON_TO_HIDE_DIALPAD); 266 hideDialpad(/* animate=*/ true, /* bottomNavVisible=*/ false); 267 return true; 268 } else if (isSearchVisible()) { 269 LogUtil.i("MainSearchController.onBackPressed", "Search is visible"); 270 Logger.get(mainActivity) 271 .logImpression( 272 isDialpadVisible() 273 ? DialerImpression.Type.MAIN_PRESS_BACK_BUTTON_TO_CLOSE_SEARCH_AND_DIALPAD 274 : DialerImpression.Type.MAIN_PRESS_BACK_BUTTON_TO_CLOSE_SEARCH); 275 closeSearch(true); 276 return true; 277 } else { 278 return false; 279 } 280 } 281 282 /** 283 * Calls {@link #hideDialpad(boolean, boolean)}, removes the search fragment and clears the 284 * dialpad. 285 */ 286 private void closeSearch(boolean animate) { 287 LogUtil.enterBlock("MainSearchController.closeSearch"); 288 Assert.checkArgument(isSearchVisible()); 289 if (isDialpadVisible()) { 290 hideDialpad(animate, /* bottomNavVisible=*/ true); 291 } else if (!fab.isShown()) { 292 fab.show(); 293 } 294 showBottomNav(); 295 toolbar.collapse(animate); 296 toolbarShadow.setVisibility(View.GONE); 297 mainActivity.getFragmentManager().popBackStack(); 298 299 // Clear the dialpad so the phone number isn't persisted between search sessions. 300 DialpadFragment dialpadFragment = getDialpadFragment(); 301 if (dialpadFragment != null) { 302 // Temporarily disable accessibility when we clear the dialpad, since it should be 303 // invisible and should not announce anything. 304 dialpadFragment 305 .getDigitsWidget() 306 .setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO); 307 dialpadFragment.clearDialpad(); 308 dialpadFragment 309 .getDigitsWidget() 310 .setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO); 311 } 312 313 notifyListenersOnSearchClose(); 314 } 315 316 @Nullable 317 protected DialpadFragment getDialpadFragment() { 318 return (DialpadFragment) 319 mainActivity.getFragmentManager().findFragmentByTag(DIALPAD_FRAGMENT_TAG); 320 } 321 322 @Nullable 323 private NewSearchFragment getSearchFragment() { 324 return (NewSearchFragment) 325 mainActivity.getFragmentManager().findFragmentByTag(SEARCH_FRAGMENT_TAG); 326 } 327 328 private boolean isDialpadVisible() { 329 DialpadFragment fragment = getDialpadFragment(); 330 return fragment != null 331 && fragment.isAdded() 332 && !fragment.isHidden() 333 && fragment.isDialpadSlideUp(); 334 } 335 336 private boolean isSearchVisible() { 337 NewSearchFragment fragment = getSearchFragment(); 338 return fragment != null && fragment.isAdded() && !fragment.isHidden(); 339 } 340 341 /** Returns true if the search UI is visible. */ 342 public boolean isInSearch() { 343 return isSearchVisible(); 344 } 345 346 /** 347 * Opens search in regular/search bar search mode. 348 * 349 * <p>Hides fab, expands toolbar and starts the search fragment. 350 */ 351 @Override 352 public void onSearchBarClicked() { 353 LogUtil.enterBlock("MainSearchController.onSearchBarClicked"); 354 Logger.get(mainActivity).logImpression(DialerImpression.Type.MAIN_CLICK_SEARCH_BAR); 355 openSearch(Optional.absent()); 356 } 357 358 private void openSearch(Optional<String> query) { 359 LogUtil.enterBlock("MainSearchController.openSearch"); 360 fab.hide(); 361 toolbar.expand(/* animate=*/ true, query); 362 toolbar.showKeyboard(); 363 toolbarShadow.setVisibility(View.VISIBLE); 364 hideBottomNav(); 365 366 FragmentTransaction transaction = mainActivity.getFragmentManager().beginTransaction(); 367 NewSearchFragment searchFragment = getSearchFragment(); 368 369 // Show Search 370 if (searchFragment == null) { 371 // TODO(a bug): zero suggest results aren't actually shown but this enabled the nearby 372 // places promo to be shown. 373 searchFragment = NewSearchFragment.newInstance(true); 374 transaction.replace(R.id.fragment_container, searchFragment, SEARCH_FRAGMENT_TAG); 375 transaction.addToBackStack(null); 376 transaction.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE); 377 } else if (!isSearchVisible()) { 378 transaction.show(getSearchFragment()); 379 } 380 381 searchFragment.setQuery( 382 query.isPresent() ? query.get() : "", CallInitiationType.Type.REGULAR_SEARCH); 383 transaction.commit(); 384 385 notifyListenersOnSearchOpen(); 386 } 387 388 @Override 389 public void onSearchBackButtonClicked() { 390 LogUtil.enterBlock("MainSearchController.onSearchBackButtonClicked"); 391 closeSearch(true); 392 } 393 394 @Override 395 public void onSearchQueryUpdated(String query) { 396 NewSearchFragment fragment = getSearchFragment(); 397 if (fragment != null) { 398 fragment.setQuery(query, CallInitiationType.Type.REGULAR_SEARCH); 399 } 400 } 401 402 /** @see OnDialpadQueryChangedListener#onDialpadQueryChanged(java.lang.String) */ 403 public void onDialpadQueryChanged(String query) { 404 query = SmartDialNameMatcher.normalizeNumber(/* context = */ mainActivity, query); 405 NewSearchFragment fragment = getSearchFragment(); 406 if (fragment != null) { 407 fragment.setQuery(query, CallInitiationType.Type.DIALPAD); 408 } 409 getDialpadFragment().process_quote_emergency_unquote(query); 410 } 411 412 @Override 413 public void onVoiceButtonClicked(VoiceSearchResultCallback voiceSearchResultCallback) { 414 Logger.get(mainActivity) 415 .logImpression(DialerImpression.Type.MAIN_CLICK_SEARCH_BAR_VOICE_BUTTON); 416 try { 417 Intent voiceIntent = new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH); 418 mainActivity.startActivityForResult(voiceIntent, ActivityRequestCodes.DIALTACTS_VOICE_SEARCH); 419 } catch (ActivityNotFoundException e) { 420 Toast.makeText(mainActivity, R.string.voice_search_not_available, Toast.LENGTH_SHORT).show(); 421 } 422 } 423 424 @Override 425 public boolean onMenuItemClicked(MenuItem menuItem) { 426 if (menuItem.getItemId() == R.id.settings) { 427 mainActivity.startActivity(new Intent(mainActivity, DialerSettingsActivity.class)); 428 Logger.get(mainActivity).logScreenView(ScreenEvent.Type.SETTINGS, mainActivity); 429 return true; 430 } else if (menuItem.getItemId() == R.id.clear_frequents) { 431 ClearFrequentsDialog.show(mainActivity.getFragmentManager()); 432 Logger.get(mainActivity).logScreenView(ScreenEvent.Type.CLEAR_FREQUENTS, mainActivity); 433 return true; 434 } else if (menuItem.getItemId() == R.id.menu_call_history) { 435 final Intent intent = new Intent(mainActivity, CallLogActivity.class); 436 mainActivity.startActivity(intent); 437 } 438 return false; 439 } 440 441 @Override 442 public void onUserLeaveHint() { 443 if (isInSearch()) { 444 closeSearch(false); 445 } 446 } 447 448 @Override 449 public void onCallPlacedFromSearch() { 450 closeSearch(false); 451 } 452 453 public void onVoiceResults(int resultCode, Intent data) { 454 if (resultCode == AppCompatActivity.RESULT_OK) { 455 ArrayList<String> matches = data.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS); 456 if (matches.size() > 0) { 457 LogUtil.i("MainSearchController.onVoiceResults", "voice search - match found"); 458 openSearch(Optional.of(matches.get(0))); 459 } else { 460 LogUtil.i("MainSearchController.onVoiceResults", "voice search - nothing heard"); 461 } 462 } else { 463 LogUtil.e("MainSearchController.onVoiceResults", "voice search failed"); 464 } 465 } 466 467 public void onSaveInstanceState(Bundle bundle) { 468 bundle.putBoolean(KEY_IS_FAB_HIDDEN, !fab.isShown()); 469 bundle.putInt(KEY_TOOLBAR_SHADOW_VISIBILITY, toolbarShadow.getVisibility()); 470 bundle.putBoolean(KEY_IS_TOOLBAR_EXPANDED, toolbar.isExpanded()); 471 bundle.putBoolean(KEY_IS_TOOLBAR_SLIDE_UP, toolbar.isSlideUp()); 472 } 473 474 public void onRestoreInstanceState(Bundle savedInstanceState) { 475 toolbarShadow.setVisibility(savedInstanceState.getInt(KEY_TOOLBAR_SHADOW_VISIBILITY)); 476 if (savedInstanceState.getBoolean(KEY_IS_FAB_HIDDEN, false)) { 477 fab.hide(); 478 } 479 if (savedInstanceState.getBoolean(KEY_IS_TOOLBAR_EXPANDED, false)) { 480 toolbar.expand(false, Optional.absent()); 481 } 482 if (savedInstanceState.getBoolean(KEY_IS_TOOLBAR_SLIDE_UP, false)) { 483 toolbar.slideUp(false); 484 } 485 } 486 487 public void addOnSearchShowListener(OnSearchShowListener listener) { 488 onSearchShowListenerList.add(listener); 489 } 490 491 public void removeOnSearchShowListener(OnSearchShowListener listener) { 492 onSearchShowListenerList.remove(listener); 493 } 494 495 private void notifyListenersOnSearchOpen() { 496 for (OnSearchShowListener listener : onSearchShowListenerList) { 497 listener.onSearchOpen(); 498 } 499 } 500 501 private void notifyListenersOnSearchClose() { 502 for (OnSearchShowListener listener : onSearchShowListenerList) { 503 listener.onSearchClose(); 504 } 505 } 506 507 /** Listener for search fragment show states change */ 508 public interface OnSearchShowListener { 509 void onSearchOpen(); 510 511 void onSearchClose(); 512 } 513 } 514