forked from organicmaps/organicmaps
Compare commits
8 commits
master
...
github/for
Author | SHA1 | Date | |
---|---|---|---|
|
3d059f6224 | ||
|
a241a68189 | ||
|
a1cb87bca6 | ||
|
7ab0c25a13 | ||
|
ea4da2a82a | ||
|
3f4b28704c | ||
|
74fac52d5e | ||
|
150eaa1c6b |
23 changed files with 1164 additions and 53 deletions
|
@ -40,6 +40,7 @@ Code contributions:
|
|||
Konstantin Pastbin
|
||||
Nishant Bhandari <nishantbhandari0019@gmail.com>
|
||||
Sebastiao Sousa <sebastiao.sousa@tecnico.ulisboa.pt>
|
||||
Fábio Gomes <gabriel.gomes@tecnico.ulisboa.pt>
|
||||
|
||||
Porting to Tizen platform:
|
||||
Sergey Pisarchik
|
||||
|
|
|
@ -1854,6 +1854,43 @@ Java_app_organicmaps_Framework_nativeDeleteSavedRoutePoints(JNIEnv *, jclass)
|
|||
frm()->GetRoutingManager().DeleteSavedRoutePoints();
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL
|
||||
Java_app_organicmaps_Framework_nativeHasSavedUserRoute(JNIEnv * env, jclass, jstring routeName)
|
||||
{
|
||||
return frm()->GetRoutingManager().HasSavedUserRoute(jni::ToNativeString(env, routeName));
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_app_organicmaps_Framework_nativeLoadUserRoutePoints(JNIEnv * env, jclass, jstring routeName)
|
||||
{
|
||||
frm()->GetRoutingManager().LoadUserRoutePoints(g_loadRouteHandler, jni::ToNativeString(env, routeName));
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_app_organicmaps_Framework_nativeSaveUserRoutePoints(JNIEnv * env, jclass, jstring routeName)
|
||||
{
|
||||
frm()->GetRoutingManager().SaveUserRoutePoints(jni::ToNativeString(env, routeName));
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_app_organicmaps_Framework_nativeDeleteUserRoute(JNIEnv * env, jclass, jstring routeName)
|
||||
{
|
||||
frm()->GetRoutingManager().DeleteUserRoute(jni::ToNativeString(env, routeName));
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_app_organicmaps_Framework_nativeRenameUserRoute(JNIEnv * env, jclass, jstring oldRouteName, jstring newRouteName)
|
||||
{
|
||||
frm()->GetRoutingManager().RenameUserRoute(jni::ToNativeString(env, oldRouteName), jni::ToNativeString(env, newRouteName));
|
||||
}
|
||||
|
||||
JNIEXPORT jobjectArray JNICALL
|
||||
Java_app_organicmaps_Framework_nativeGetUserRouteNames(JNIEnv * env, jclass)
|
||||
{
|
||||
auto routeNames = frm()->GetRoutingManager().GetUserRouteNames();
|
||||
return jni::ToJavaStringArray(env, routeNames);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_app_organicmaps_Framework_nativeShowFeature(JNIEnv * env, jclass, jobject featureId)
|
||||
{
|
||||
|
|
|
@ -421,6 +421,13 @@ public class Framework
|
|||
public static native void nativeSaveRoutePoints();
|
||||
public static native void nativeDeleteSavedRoutePoints();
|
||||
|
||||
public static native boolean nativeHasSavedUserRoute(@NonNull String routeName);
|
||||
public static native void nativeLoadUserRoutePoints(@NonNull String routeName);
|
||||
public static native void nativeSaveUserRoutePoints(@NonNull String routeName);
|
||||
public static native void nativeDeleteUserRoute(@NonNull String routeName);
|
||||
public static native void nativeRenameUserRoute(@NonNull String oldRouteName, @NonNull String newRouteName);
|
||||
public static native String[] nativeGetUserRouteNames();
|
||||
|
||||
public static native void nativeShowFeature(@NonNull FeatureId featureId);
|
||||
|
||||
public static native void nativeMakeCrash();
|
||||
|
|
|
@ -72,6 +72,7 @@ import app.organicmaps.location.SensorHelper;
|
|||
import app.organicmaps.location.SensorListener;
|
||||
import app.organicmaps.maplayer.MapButtonsController;
|
||||
import app.organicmaps.maplayer.MapButtonsViewModel;
|
||||
import app.organicmaps.maplayer.MyRoutesFragment;
|
||||
import app.organicmaps.maplayer.ToggleMapLayerFragment;
|
||||
import app.organicmaps.maplayer.isolines.IsolinesManager;
|
||||
import app.organicmaps.maplayer.isolines.IsolinesState;
|
||||
|
@ -156,6 +157,7 @@ public class MwmActivity extends BaseMwmFragmentActivity
|
|||
|
||||
private static final String MAIN_MENU_ID = "MAIN_MENU_BOTTOM_SHEET";
|
||||
private static final String LAYERS_MENU_ID = "LAYERS_MENU_BOTTOM_SHEET";
|
||||
private static final String MYROUTES_MENU_ID = "MYROUTES_MENU_BOTTOM_SHEET";
|
||||
|
||||
@Nullable
|
||||
private MapFragment mMapFragment;
|
||||
|
@ -774,6 +776,11 @@ public class MwmActivity extends BaseMwmFragmentActivity
|
|||
showBottomSheet(MAIN_MENU_ID);
|
||||
}
|
||||
case help -> showHelp();
|
||||
case myRoutes ->
|
||||
{
|
||||
closeFloatingPanels();
|
||||
showBottomSheet(MYROUTES_MENU_ID);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -892,6 +899,7 @@ public class MwmActivity extends BaseMwmFragmentActivity
|
|||
{
|
||||
closeBottomSheet(LAYERS_MENU_ID);
|
||||
closeBottomSheet(MAIN_MENU_ID);
|
||||
closeBottomSheet(MYROUTES_MENU_ID);
|
||||
closePlacePage();
|
||||
}
|
||||
|
||||
|
@ -1147,7 +1155,7 @@ public class MwmActivity extends BaseMwmFragmentActivity
|
|||
public void onBackPressed()
|
||||
{
|
||||
final RoutingController routingController = RoutingController.get();
|
||||
if (!closeBottomSheet(MAIN_MENU_ID) && !closeBottomSheet(LAYERS_MENU_ID) &&
|
||||
if (!closeBottomSheet(MAIN_MENU_ID) && !closeBottomSheet(LAYERS_MENU_ID) && !closeBottomSheet(MYROUTES_MENU_ID) &&
|
||||
!collapseNavMenu() && !closePlacePage() && !closeSearchToolbar(true, true) &&
|
||||
!closeSidePanel() && !closePositionChooser() &&
|
||||
!routingController.resetToPlanningStateIfNavigating() && !routingController.cancel())
|
||||
|
@ -2164,6 +2172,8 @@ public class MwmActivity extends BaseMwmFragmentActivity
|
|||
{
|
||||
if (id.equals(LAYERS_MENU_ID))
|
||||
return new ToggleMapLayerFragment();
|
||||
else if (id.equals(MYROUTES_MENU_ID))
|
||||
return new MyRoutesFragment();
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
@ -44,6 +44,8 @@ public class MapButtonsController extends Fragment
|
|||
private View mBottomButtonsFrame;
|
||||
@Nullable
|
||||
private FloatingActionButton mToggleMapLayerButton;
|
||||
@Nullable
|
||||
private FloatingActionButton mMyRoutesButton;
|
||||
|
||||
@Nullable
|
||||
private MyPositionButton mNavMyPosition;
|
||||
|
@ -104,6 +106,11 @@ public class MapButtonsController extends Fragment
|
|||
final View myPosition = mFrame.findViewById(R.id.my_position);
|
||||
mNavMyPosition = new MyPositionButton(myPosition, (v) -> mMapButtonClickListener.onMapButtonClick(MapButtons.myPosition));
|
||||
|
||||
mMyRoutesButton = mFrame.findViewById(R.id.btn_myroutes);
|
||||
if (mMyRoutesButton != null)
|
||||
{
|
||||
mMyRoutesButton.setOnClickListener(view -> mMapButtonClickListener.onMapButtonClick(MapButtons.myRoutes));
|
||||
}
|
||||
// Some buttons do not exist in navigation mode
|
||||
mToggleMapLayerButton = mFrame.findViewById(R.id.layers_button);
|
||||
if (mToggleMapLayerButton != null)
|
||||
|
@ -143,6 +150,8 @@ public class MapButtonsController extends Fragment
|
|||
mButtonsMap.put(MapButtons.bookmarks, bookmarksButton);
|
||||
mButtonsMap.put(MapButtons.search, searchButton);
|
||||
|
||||
if (mMyRoutesButton != null)
|
||||
mButtonsMap.put(MapButtons.myRoutes, mMyRoutesButton);
|
||||
if (mToggleMapLayerButton != null)
|
||||
mButtonsMap.put(MapButtons.toggleMapLayer, mToggleMapLayerButton);
|
||||
if (menuButton != null)
|
||||
|
@ -181,6 +190,11 @@ public class MapButtonsController extends Fragment
|
|||
case bookmarks:
|
||||
case menu:
|
||||
UiUtils.showIf(show, buttonView);
|
||||
break;
|
||||
case myRoutes:
|
||||
UiUtils.showIf(show, mMyRoutesButton);
|
||||
break;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -350,7 +364,8 @@ public class MapButtonsController extends Fragment
|
|||
search,
|
||||
bookmarks,
|
||||
menu,
|
||||
help
|
||||
help,
|
||||
myRoutes
|
||||
}
|
||||
|
||||
public interface MapButtonClickListener
|
||||
|
|
|
@ -0,0 +1,266 @@
|
|||
package app.organicmaps.maplayer;
|
||||
|
||||
import android.app.AlertDialog;
|
||||
import android.os.Bundle;
|
||||
import android.os.SystemClock;
|
||||
import android.text.InputFilter;
|
||||
import android.text.InputType;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.WindowManager;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.google.android.material.button.MaterialButton;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import app.organicmaps.Framework;
|
||||
import app.organicmaps.R;
|
||||
import app.organicmaps.util.bottomsheet.MenuBottomSheetFragment;
|
||||
|
||||
public class MyRoutesFragment extends Fragment
|
||||
{
|
||||
private static final String MYROUTES_MENU_ID = "MYROUTES_MENU_BOTTOM_SHEET";
|
||||
@Nullable
|
||||
private RoutesAdapter mAdapter;
|
||||
private MapButtonsViewModel mMapButtonsViewModel;
|
||||
private String mEditText = "";
|
||||
private String mDialogCaller = "";
|
||||
private RouteBottomSheetItem mCurrentItem = null;
|
||||
private static final String SAVE_ID = "SAVE_ID";
|
||||
private static final String RENAME_ID = "RENAME_ID";
|
||||
private static final String DELETE_ID = "DELETE_ID";
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState)
|
||||
{
|
||||
View mRoot = inflater.inflate(R.layout.fragment_myroutes, container, false);
|
||||
|
||||
mMapButtonsViewModel = new ViewModelProvider(requireActivity()).get(MapButtonsViewModel.class);
|
||||
MaterialButton mCloseButton = mRoot.findViewById(R.id.close_button);
|
||||
mCloseButton.setOnClickListener(view -> closeMyRoutesBottomSheet());
|
||||
|
||||
Button mSaveButton = mRoot.findViewById(R.id.save_button);
|
||||
if (Framework.nativeGetRoutePoints().length >= 2)
|
||||
mSaveButton.setOnClickListener(view -> onSaveButtonClick());
|
||||
else
|
||||
mSaveButton.setEnabled(false);
|
||||
|
||||
initRecycler(mRoot);
|
||||
return mRoot;
|
||||
}
|
||||
|
||||
private void initRecycler(@NonNull View root)
|
||||
{
|
||||
RecyclerView recycler = root.findViewById(R.id.recycler);
|
||||
RecyclerView.LayoutManager layoutManager = new LinearLayoutManager(requireContext(),
|
||||
LinearLayoutManager.VERTICAL,
|
||||
false);
|
||||
recycler.setLayoutManager(layoutManager);
|
||||
mAdapter = new RoutesAdapter(getRouteItems());
|
||||
recycler.setAdapter(mAdapter);
|
||||
recycler.setNestedScrollingEnabled(false);
|
||||
}
|
||||
|
||||
private List<RouteBottomSheetItem> getRouteItems()
|
||||
{
|
||||
String[] savedRouteNames = Framework.nativeGetUserRouteNames();
|
||||
List<RouteBottomSheetItem> items = new ArrayList<>();
|
||||
for (String routeName : savedRouteNames)
|
||||
items.add(createItem(routeName));
|
||||
return items;
|
||||
}
|
||||
|
||||
private RouteBottomSheetItem createItem(String routeName)
|
||||
{
|
||||
return RouteBottomSheetItem.create(routeName, this::onItemTitleClick, this::onItemRenameClick, this::onItemDeleteClick);
|
||||
}
|
||||
|
||||
private void onSaveButtonClick()
|
||||
{
|
||||
mEditText = "";
|
||||
mDialogCaller = SAVE_ID;
|
||||
showTextInputDialog("");
|
||||
}
|
||||
|
||||
private void checkSave()
|
||||
{
|
||||
String newRouteName = mEditText;
|
||||
|
||||
if (newRouteName.isEmpty())
|
||||
return;
|
||||
if (Framework.nativeHasSavedUserRoute(newRouteName))
|
||||
{
|
||||
showConfirmationDialog(getString(R.string.user_route_overwrite_title, newRouteName),
|
||||
getString(R.string.user_route_overwrite_body, newRouteName),
|
||||
R.string.overwrite);
|
||||
return;
|
||||
}
|
||||
save(false);
|
||||
}
|
||||
|
||||
private void save(boolean isOverwrite)
|
||||
{
|
||||
String newRouteName = mEditText;
|
||||
|
||||
if (isOverwrite)
|
||||
Framework.nativeDeleteUserRoute(newRouteName);
|
||||
|
||||
Framework.nativeSaveUserRoutePoints(newRouteName);
|
||||
|
||||
if (!isOverwrite)
|
||||
mAdapter.addRoute(createItem(newRouteName));
|
||||
}
|
||||
|
||||
private void onItemTitleClick(@NonNull View v, @NonNull RouteBottomSheetItem item)
|
||||
{
|
||||
Framework.nativeLoadUserRoutePoints(item.getRouteName());
|
||||
}
|
||||
|
||||
private void onItemRenameClick(@NonNull View v, @NonNull RouteBottomSheetItem item)
|
||||
{
|
||||
mEditText = "";
|
||||
mDialogCaller = RENAME_ID;
|
||||
mCurrentItem = item;
|
||||
showTextInputDialog(item.getRouteName());
|
||||
}
|
||||
|
||||
private void checkRename()
|
||||
{
|
||||
String newRouteName = mEditText;
|
||||
|
||||
if (newRouteName.isEmpty())
|
||||
return;
|
||||
|
||||
if (newRouteName.equals(mCurrentItem.getRouteName()))
|
||||
return;
|
||||
if (Framework.nativeHasSavedUserRoute(newRouteName))
|
||||
{
|
||||
showConfirmationDialog(getString(R.string.user_route_overwrite_title, newRouteName),
|
||||
getString(R.string.user_route_overwrite_body, newRouteName),
|
||||
R.string.overwrite);
|
||||
return;
|
||||
}
|
||||
rename(false);
|
||||
}
|
||||
|
||||
private void rename(boolean isOverwrite)
|
||||
{
|
||||
String newRouteName = mEditText;
|
||||
|
||||
if (isOverwrite)
|
||||
{
|
||||
Framework.nativeDeleteUserRoute(newRouteName);
|
||||
// Sometimes delete takes too long and renaming cannot happen; thus, sleep
|
||||
SystemClock.sleep(250); // TODO(Fábio Gomes) find a better solution
|
||||
}
|
||||
|
||||
Framework.nativeRenameUserRoute(mCurrentItem.getRouteName(), newRouteName);
|
||||
|
||||
mAdapter.removeRoute(mCurrentItem);
|
||||
if (!isOverwrite)
|
||||
mAdapter.addRoute(createItem(newRouteName));
|
||||
}
|
||||
|
||||
private void onItemDeleteClick(@NonNull View v, @NonNull RouteBottomSheetItem item)
|
||||
{
|
||||
mDialogCaller = DELETE_ID;
|
||||
mCurrentItem = item;
|
||||
showConfirmationDialog(getString(R.string.user_route_delete_title, item.getRouteName()),
|
||||
getString(R.string.user_route_delete_body),
|
||||
R.string.delete);
|
||||
}
|
||||
|
||||
private void delete()
|
||||
{
|
||||
Framework.nativeDeleteUserRoute(mCurrentItem.getRouteName());
|
||||
mAdapter.removeRoute(mCurrentItem);
|
||||
}
|
||||
|
||||
private void closeMyRoutesBottomSheet()
|
||||
{
|
||||
MenuBottomSheetFragment bottomSheet =
|
||||
(MenuBottomSheetFragment) requireActivity().getSupportFragmentManager().findFragmentByTag(MYROUTES_MENU_ID);
|
||||
if (bottomSheet != null)
|
||||
bottomSheet.dismiss();
|
||||
}
|
||||
|
||||
private void showConfirmationDialog(String title, String message, @StringRes int buttonText)
|
||||
{
|
||||
AlertDialog dialog = new AlertDialog.Builder(this.getContext(), R.style.MwmTheme_AlertDialog)
|
||||
.setTitle(title)
|
||||
.setMessage(message)
|
||||
.setPositiveButton(buttonText, (dialogInterface, i) -> {
|
||||
switch (mDialogCaller) {
|
||||
case SAVE_ID -> save(true);
|
||||
case RENAME_ID -> rename(true);
|
||||
case DELETE_ID -> delete();
|
||||
}
|
||||
})
|
||||
.setNegativeButton(R.string.cancel, (dialogInterface, i) -> {
|
||||
switch (mDialogCaller) {
|
||||
case SAVE_ID, RENAME_ID -> retryInput();
|
||||
}
|
||||
})
|
||||
.create();
|
||||
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
private void showTextInputDialog(String defaultText)
|
||||
{
|
||||
EditText input = new EditText(this.getContext());
|
||||
input.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_CAP_SENTENCES);
|
||||
input.setFilters(getInputFilters());
|
||||
input.setText(defaultText);
|
||||
|
||||
AlertDialog dialog = new AlertDialog.Builder(this.getContext(), R.style.MwmTheme_AlertDialog)
|
||||
.setTitle(R.string.user_route_input_title)
|
||||
.setView(input)
|
||||
.setPositiveButton(R.string.ok, (dialogInterface, i) -> {
|
||||
mEditText = input.getText().toString();
|
||||
switch (mDialogCaller) {
|
||||
case SAVE_ID -> checkSave();
|
||||
case RENAME_ID -> checkRename();
|
||||
}
|
||||
})
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.create();
|
||||
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
private void retryInput()
|
||||
{
|
||||
showTextInputDialog(mEditText);
|
||||
}
|
||||
|
||||
private InputFilter[] getInputFilters()
|
||||
{
|
||||
InputFilter filter = (source, start, end, dest, dstart, dend) -> {
|
||||
for (int i = start; i < end; i++)
|
||||
{
|
||||
char current = source.charAt(i);
|
||||
if (!Character.isSpaceChar(current) && !Character.isLetterOrDigit(current))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return new InputFilter[] {filter, new InputFilter.LengthFilter(32)};
|
||||
}
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
package app.organicmaps.maplayer;
|
||||
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import app.organicmaps.adapter.OnItemClickListener;
|
||||
|
||||
public class RouteBottomSheetItem
|
||||
{
|
||||
@NonNull
|
||||
private final String mRouteName;
|
||||
@NonNull
|
||||
private final OnItemClickListener<RouteBottomSheetItem> mItemTitleClickListener;
|
||||
@NonNull
|
||||
private final OnItemClickListener<RouteBottomSheetItem> mItemRenameClickListener;
|
||||
@NonNull
|
||||
private final OnItemClickListener<RouteBottomSheetItem> mItemDeleteClickListener;
|
||||
|
||||
RouteBottomSheetItem(@NonNull String routeName,
|
||||
@NonNull OnItemClickListener<RouteBottomSheetItem> itemTitleClickListener,
|
||||
@NonNull OnItemClickListener<RouteBottomSheetItem> itemRenameClickListener,
|
||||
@NonNull OnItemClickListener<RouteBottomSheetItem> itemDeleteClickListener)
|
||||
{
|
||||
mRouteName = routeName;
|
||||
mItemTitleClickListener = itemTitleClickListener;
|
||||
mItemRenameClickListener = itemRenameClickListener;
|
||||
mItemDeleteClickListener = itemDeleteClickListener;
|
||||
}
|
||||
|
||||
public static RouteBottomSheetItem create(@NonNull String routeName,
|
||||
@NonNull OnItemClickListener<RouteBottomSheetItem> routeItemTitleClickListener,
|
||||
@NonNull OnItemClickListener<RouteBottomSheetItem> routeItemRenameClickListener,
|
||||
@NonNull OnItemClickListener<RouteBottomSheetItem> routeItemDeleteClickListener)
|
||||
{
|
||||
return new RouteBottomSheetItem(routeName, routeItemTitleClickListener, routeItemRenameClickListener, routeItemDeleteClickListener);
|
||||
}
|
||||
|
||||
public String getRouteName()
|
||||
{
|
||||
return mRouteName;
|
||||
}
|
||||
|
||||
public void onTitleClick(@NonNull View v, @NonNull RouteBottomSheetItem item)
|
||||
{
|
||||
mItemTitleClickListener.onItemClick(v, item);
|
||||
}
|
||||
|
||||
public void onRenameClick(@NonNull View v, @NonNull RouteBottomSheetItem item)
|
||||
{
|
||||
mItemRenameClickListener.onItemClick(v, item);
|
||||
}
|
||||
|
||||
public void onDeleteClick(@NonNull View v, @NonNull RouteBottomSheetItem item)
|
||||
{
|
||||
mItemDeleteClickListener.onItemClick(v, item);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
package app.organicmaps.maplayer;
|
||||
|
||||
import android.view.View;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import app.organicmaps.R;
|
||||
import app.organicmaps.adapter.OnItemClickListener;
|
||||
|
||||
class RouteHolder extends RecyclerView.ViewHolder
|
||||
{
|
||||
@NonNull
|
||||
final TextView mTitle;
|
||||
@NonNull
|
||||
final ImageView mRenameButton;
|
||||
@NonNull
|
||||
final ImageView mDeleteButton;
|
||||
@Nullable
|
||||
RouteBottomSheetItem mItem;
|
||||
@Nullable
|
||||
OnItemClickListener<RouteBottomSheetItem> mTitleListener;
|
||||
@Nullable
|
||||
OnItemClickListener<RouteBottomSheetItem> mRenameListener;
|
||||
@Nullable
|
||||
OnItemClickListener<RouteBottomSheetItem> mDeleteListener;
|
||||
|
||||
RouteHolder(@NonNull View root)
|
||||
{
|
||||
super(root);
|
||||
mTitle = root.findViewById(R.id.name);
|
||||
mTitle.setOnClickListener(this::onItemTitleClicked);
|
||||
mRenameButton = root.findViewById(R.id.rename);
|
||||
mRenameButton.setOnClickListener(this::onItemRenameClicked);
|
||||
mDeleteButton = root.findViewById(R.id.delete);
|
||||
mDeleteButton.setOnClickListener(this::onItemDeleteClicked);
|
||||
}
|
||||
|
||||
public void onItemTitleClicked(@NonNull View v)
|
||||
{
|
||||
if (mTitleListener != null && mItem != null)
|
||||
mTitleListener.onItemClick(v, mItem);
|
||||
}
|
||||
|
||||
public void onItemRenameClicked(@NonNull View v)
|
||||
{
|
||||
if (mRenameListener != null && mItem != null)
|
||||
mRenameListener.onItemClick(v, mItem);
|
||||
}
|
||||
|
||||
public void onItemDeleteClicked(@NonNull View v)
|
||||
{
|
||||
if (mDeleteListener != null && mItem != null)
|
||||
mDeleteListener.onItemClick(v, mItem);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,83 @@
|
|||
package app.organicmaps.maplayer;
|
||||
|
||||
import android.content.Context;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import app.organicmaps.R;
|
||||
import app.organicmaps.util.SharedPropertiesUtils;
|
||||
import app.organicmaps.util.UiUtils;
|
||||
import app.organicmaps.util.log.Logger;
|
||||
|
||||
public class RoutesAdapter extends RecyclerView.Adapter<RouteHolder>
|
||||
{
|
||||
@NonNull
|
||||
private final List<RouteBottomSheetItem> mItems;
|
||||
|
||||
public RoutesAdapter(@NonNull List<RouteBottomSheetItem> items)
|
||||
{
|
||||
mItems = items;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public RouteHolder onCreateViewHolder(ViewGroup parent, int viewType)
|
||||
{
|
||||
LayoutInflater inflater = LayoutInflater.from(parent.getContext());
|
||||
View root = inflater.inflate(R.layout.item_myroute_button, parent, false);
|
||||
return new RouteHolder(root);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(RouteHolder holder, int position)
|
||||
{
|
||||
RouteBottomSheetItem item = mItems.get(position);
|
||||
holder.mItem = item;
|
||||
|
||||
holder.mTitle.setSelected(true);
|
||||
holder.mTitle.setText(item.getRouteName());
|
||||
|
||||
holder.mTitleListener = item::onTitleClick;
|
||||
holder.mRenameListener = item::onRenameClick;
|
||||
holder.mDeleteListener = item::onDeleteClick;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount()
|
||||
{
|
||||
return mItems.size();
|
||||
}
|
||||
|
||||
public void addRoute(@NonNull RouteBottomSheetItem item)
|
||||
{
|
||||
// Compare strings toUpperCase to ignore case
|
||||
String routeName = item.getRouteName().toUpperCase();
|
||||
String iName;
|
||||
// Find index to add ordered
|
||||
int pos = mItems.size();
|
||||
for (int i = 0; i < mItems.size(); i++)
|
||||
{
|
||||
iName = mItems.get(i).getRouteName().toUpperCase();
|
||||
if(routeName.compareTo(iName) < 0)
|
||||
{
|
||||
pos = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
mItems.add(pos, item);
|
||||
notifyItemInserted(pos);
|
||||
}
|
||||
|
||||
public void removeRoute(@NonNull RouteBottomSheetItem item)
|
||||
{
|
||||
int pos = mItems.indexOf(item);
|
||||
mItems.remove(item);
|
||||
notifyItemRemoved(pos);
|
||||
}
|
||||
}
|
|
@ -33,6 +33,14 @@
|
|||
android:layout_marginBottom="@dimen/margin_half"
|
||||
app:layout_constraintBottom_toTopOf="@+id/btn_bookmarks"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
<include
|
||||
layout="@layout/map_buttons_myroutes"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentBottom="true"
|
||||
android:layout_marginBottom="@dimen/margin_half"
|
||||
app:layout_constraintBottom_toTopOf="@+id/btn_search"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/map_buttons_inner_right"
|
||||
|
|
|
@ -57,4 +57,12 @@
|
|||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentBottom="true"
|
||||
android:layout_alignParentEnd="true" />
|
||||
</RelativeLayout>
|
||||
<include
|
||||
layout="@layout/map_buttons_myroutes"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentBottom="true"
|
||||
android:layout_marginBottom="@dimen/margin_half"
|
||||
android:layout_alignParentStart="true"
|
||||
android:layout_marginStart="@dimen/margin_half" />
|
||||
</RelativeLayout>
|
||||
|
|
65
android/app/src/main/res/layout/fragment_myroutes.xml
Normal file
65
android/app/src/main/res/layout/fragment_myroutes.xml
Normal file
|
@ -0,0 +1,65 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:orientation="vertical">
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
<TextView
|
||||
android:id="@+id/layers_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/margin_base"
|
||||
android:layout_marginTop="@dimen/margin_base"
|
||||
android:text="@string/user_route_title"
|
||||
android:textAppearance="?fontHeadline6"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/close_button"
|
||||
style="@style/Widget.MaterialComponents.Button.UnelevatedButton"
|
||||
android:layout_width="@dimen/place_page_top_button"
|
||||
android:layout_height="@dimen/place_page_top_button"
|
||||
android:layout_marginEnd="@dimen/margin_half_plus"
|
||||
android:layout_marginTop="@dimen/margin_half_plus"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/close"
|
||||
app:icon="@drawable/ic_close"
|
||||
app:iconGravity="textStart"
|
||||
app:iconPadding="0dp"
|
||||
app:iconSize="24dp"
|
||||
app:iconTint="?iconTint"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Button.Round" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
<Button
|
||||
android:id="@+id/save_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/user_route_save"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:padding="16dp"/>
|
||||
|
||||
<androidx.core.widget.NestedScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?cardBackground"
|
||||
android:clipToPadding="false"
|
||||
android:paddingBottom="@dimen/action_bar_extended_height"
|
||||
android:scrollbars="none"/>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
<View
|
||||
android:id="@+id/divider_bottom"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="?dividerHorizontal"/>
|
||||
</LinearLayout>
|
50
android/app/src/main/res/layout/item_myroute_button.xml
Normal file
50
android/app/src/main/res/layout/item_myroute_button.xml
Normal file
|
@ -0,0 +1,50 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="@dimen/height_item_edit_bookmark"
|
||||
android:background="?clickableBackground">
|
||||
|
||||
<Button
|
||||
android:id="@+id/name"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:background="?selectableItemBackgroundBorderless"
|
||||
android:layout_marginTop="@dimen/margin_base"
|
||||
android:layout_marginStart="8dp"
|
||||
android:paddingHorizontal="8dp"
|
||||
android:layout_toStartOf="@+id/rename"
|
||||
android:layout_alignParentStart="true"
|
||||
android:layout_centerVertical="true"
|
||||
android:ellipsize="middle"
|
||||
android:singleLine="true"
|
||||
android:textAppearance="?fontBody1"
|
||||
tools:text="Route name looooooooooooooooooongasdasdasd"
|
||||
android:gravity="center"
|
||||
android:clickable="true"
|
||||
android:focusable="true"/>
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/rename"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:background="?selectableItemBackgroundBorderless"
|
||||
android:layout_toStartOf="@+id/delete"
|
||||
android:paddingHorizontal="8dp"
|
||||
app:srcCompat="@drawable/ic_edit"
|
||||
app:tint="?secondary"
|
||||
android:importantForAccessibility="no" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/delete"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:background="?selectableItemBackgroundBorderless"
|
||||
android:paddingHorizontal="@dimen/margin_half"
|
||||
app:srcCompat="@drawable/ic_delete"
|
||||
app:tint="?secondary"
|
||||
android:importantForAccessibility="no" />
|
||||
</RelativeLayout>
|
|
@ -8,32 +8,43 @@
|
|||
android:clipChildren="false"
|
||||
android:clipToPadding="false"
|
||||
tools:background="@color/bg_primary">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/map_buttons_inner_left"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginTop="@dimen/map_buttons_bottom_margin"
|
||||
android:layout_marginBottom="@dimen/map_buttons_bottom_margin"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false"
|
||||
android:padding="@dimen/nav_frame_padding"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent">
|
||||
<include
|
||||
layout="@layout/map_buttons_bookmarks"
|
||||
android:id="@+id/map_buttons_inner_left"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentBottom="true"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginBottom="@dimen/map_buttons_bottom_margin"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false"
|
||||
android:padding="@dimen/nav_frame_padding"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
app:layout_constraintStart_toStartOf="parent">
|
||||
|
||||
<include
|
||||
layout="@layout/map_buttons_search"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentBottom="true"
|
||||
android:layout_marginBottom="@dimen/margin_half"
|
||||
app:layout_constraintBottom_toTopOf="@+id/btn_bookmarks"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
layout="@layout/map_buttons_bookmarks"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentBottom="true"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
<include
|
||||
layout="@layout/map_buttons_search"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentBottom="true"
|
||||
android:layout_marginBottom="@dimen/margin_half"
|
||||
app:layout_constraintBottom_toTopOf="@+id/btn_bookmarks"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
<include
|
||||
layout="@layout/map_buttons_myroutes"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentBottom="true"
|
||||
android:layout_marginBottom="@dimen/margin_half"
|
||||
app:layout_constraintBottom_toTopOf="@+id/btn_search"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/map_buttons_inner_right"
|
||||
|
|
|
@ -57,4 +57,11 @@
|
|||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentBottom="true"
|
||||
android:layout_alignParentEnd="true" />
|
||||
</RelativeLayout>
|
||||
<include
|
||||
layout="@layout/map_buttons_myroutes"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentBottom="true"
|
||||
android:layout_marginBottom="@dimen/margin_half"
|
||||
android:layout_toStartOf="@+id/map_buttons_bottom" />
|
||||
</RelativeLayout>
|
||||
|
|
8
android/app/src/main/res/layout/map_buttons_myroutes.xml
Normal file
8
android/app/src/main/res/layout/map_buttons_myroutes.xml
Normal file
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/btn_myroutes"
|
||||
style="@style/MwmWidget.MapButton"
|
||||
android:contentDescription="@string/tracks_title"
|
||||
app:srcCompat="@drawable/ic_bookmark_start" />
|
|
@ -108,6 +108,18 @@
|
|||
<string name="measurement_units">Unidades de medida</string>
|
||||
<!-- Detailed description of Measurement Units settings button -->
|
||||
<string name="measurement_units_summary">Escolha entre milhas e quilômetros</string>
|
||||
<!-- Button text -->
|
||||
<string name="overwrite">Sobrescrever</string>
|
||||
<!-- Button text which saves user route -->
|
||||
<string name="user_route_save">Salvar rota atual</string>
|
||||
<!-- Title in the user routes fragment -->
|
||||
<string name="user_route_title">Minhas Rotas</string>
|
||||
<!-- Title in dialog to confirm user route deletion -->
|
||||
<string name="user_route_delete_title">Apagar %1$s?</string>
|
||||
<!-- Text in dialog to confirm user route deletion -->
|
||||
<string name="user_route_delete_body">Essa ação não pode ser desfeita.</string>
|
||||
<!-- Title in dialog to input the name of a user route -->
|
||||
<string name="user_route_input_title">Nome da rota</string>
|
||||
|
||||
<!-- SECTION: Search categories -->
|
||||
<!-- Search category for cafes, bars, restaurants; any changes should be duplicated in categories.txt @category_eat! -->
|
||||
|
|
|
@ -100,6 +100,22 @@
|
|||
<string name="measurement_units">Unidades de medida</string>
|
||||
<!-- Detailed description of Measurement Units settings button -->
|
||||
<string name="measurement_units_summary">Escolha entre milhas e quilómetros</string>
|
||||
<!-- Button text -->
|
||||
<string name="overwrite">Sobrescrever</string>
|
||||
<!-- Button text which saves user route -->
|
||||
<string name="user_route_save">Guardar rota atual</string>
|
||||
<!-- Title in the user routes fragment -->
|
||||
<string name="user_route_title">As Minhas Rotas</string>
|
||||
<!-- Title in dialog to confirm user route overwrite -->
|
||||
<string name="user_route_overwrite_title">%1$s já existe</string>
|
||||
<!-- Text in dialog to confirm user route overwrite -->
|
||||
<string name="user_route_overwrite_body">Sobrescrever %1$s existente?</string>
|
||||
<!-- Title in dialog to confirm user route deletion -->
|
||||
<string name="user_route_delete_title">Eliminar %1$s?</string>
|
||||
<!-- Text in dialog to confirm user route deletion -->
|
||||
<string name="user_route_delete_body">Esta ação não pode ser desfeita.</string>
|
||||
<!-- Title in dialog to input the name of a user route -->
|
||||
<string name="user_route_input_title">Nome da rota</string>
|
||||
|
||||
<!-- SECTION: Search categories -->
|
||||
<!-- Search category for cafes, bars, restaurants; any changes should be duplicated in categories.txt @category_eat! -->
|
||||
|
|
|
@ -115,6 +115,22 @@
|
|||
<string name="measurement_units">Measurement units</string>
|
||||
<!-- Detailed description of Measurement Units settings button -->
|
||||
<string name="measurement_units_summary">Choose between miles and kilometers</string>
|
||||
<!-- Button text -->
|
||||
<string name="overwrite">Overwrite</string>
|
||||
<!-- Button text which saves user route -->
|
||||
<string name="user_route_save">Save current route</string>
|
||||
<!-- Title in the user routes fragment -->
|
||||
<string name="user_route_title">My Routes</string>
|
||||
<!-- Title in dialog to confirm user route overwrite -->
|
||||
<string name="user_route_overwrite_title">%1$s already exists</string>
|
||||
<!-- Text in dialog to confirm user route overwrite -->
|
||||
<string name="user_route_overwrite_body">Overwrite existing %1$s?</string>
|
||||
<!-- Title in dialog to confirm user route deletion -->
|
||||
<string name="user_route_delete_title">Delete %1$s?</string>
|
||||
<!-- Text in dialog to confirm user route deletion -->
|
||||
<string name="user_route_delete_body">This action cannot be undone.</string>
|
||||
<!-- Title in dialog to input the name of a user route -->
|
||||
<string name="user_route_input_title">Route name</string>
|
||||
|
||||
<!-- SECTION: Search categories -->
|
||||
<!-- Search category for cafes, bars, restaurants; any changes should be duplicated in categories.txt @category_eat! -->
|
||||
|
|
|
@ -16,6 +16,7 @@ set(SRC
|
|||
power_manager_tests.cpp
|
||||
search_api_tests.cpp
|
||||
transliteration_test.cpp
|
||||
user_routes_test.cpp
|
||||
working_time_tests.cpp
|
||||
)
|
||||
|
||||
|
|
265
map/map_tests/user_routes_test.cpp
Normal file
265
map/map_tests/user_routes_test.cpp
Normal file
|
@ -0,0 +1,265 @@
|
|||
#include "testing/testing.hpp"
|
||||
|
||||
#include "map/routing_manager.hpp"
|
||||
#include "map/routing_mark.hpp"
|
||||
|
||||
#include "geometry/point2d.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <set>
|
||||
#include <thread>
|
||||
#include <chrono>
|
||||
|
||||
namespace user_routes_test
|
||||
{
|
||||
using namespace std;
|
||||
|
||||
using Runner = Platform::ThreadRunner;
|
||||
|
||||
string const kTestRouteName1 = "My Test Route";
|
||||
string const kTestRouteName2 = "My Other Test Route";
|
||||
|
||||
#define RM_CALLBACKS { \
|
||||
static_cast<RoutingManager::Callbacks::DataSourceGetterFn>(nullptr), \
|
||||
static_cast<RoutingManager::Callbacks::CountryInfoGetterFn>(nullptr), \
|
||||
static_cast<RoutingManager::Callbacks::CountryParentNameGetterFn>(nullptr), \
|
||||
static_cast<RoutingManager::Callbacks::GetStringsBundleFn>(nullptr), \
|
||||
static_cast<RoutingManager::Callbacks::PowerManagerGetter>(nullptr) \
|
||||
}
|
||||
|
||||
#define BM_CALLBACKS { \
|
||||
[]() -> StringsBundle const & \
|
||||
{ \
|
||||
static StringsBundle const dummyBundle; \
|
||||
return dummyBundle; \
|
||||
}, \
|
||||
static_cast<BookmarkManager::Callbacks::GetSeacrhAPIFn>(nullptr), \
|
||||
static_cast<BookmarkManager::Callbacks::CreatedBookmarksCallback>(nullptr), \
|
||||
static_cast<BookmarkManager::Callbacks::UpdatedBookmarksCallback>(nullptr), \
|
||||
static_cast<BookmarkManager::Callbacks::DeletedBookmarksCallback>(nullptr), \
|
||||
static_cast<BookmarkManager::Callbacks::AttachedBookmarksCallback>(nullptr), \
|
||||
static_cast<BookmarkManager::Callbacks::DetachedBookmarksCallback>(nullptr) \
|
||||
}
|
||||
|
||||
RouteMarkData getRouteMarkStart()
|
||||
{
|
||||
RouteMarkData mark;
|
||||
mark.m_title = "Title 1";
|
||||
mark.m_subTitle = "Sub 1";
|
||||
mark.m_position.x = 0;
|
||||
mark.m_position.y = 0;
|
||||
mark.m_pointType = RouteMarkType::Start;
|
||||
|
||||
return mark;
|
||||
}
|
||||
|
||||
RouteMarkData getRouteMarkFinish()
|
||||
{
|
||||
RouteMarkData mark;
|
||||
mark.m_title = "Title 2";
|
||||
mark.m_subTitle = "Sub 2";
|
||||
mark.m_position.x = 1;
|
||||
mark.m_position.y = 1;
|
||||
mark.m_pointType = RouteMarkType::Finish;
|
||||
|
||||
return mark;
|
||||
}
|
||||
|
||||
void awaitFileSaving(RoutingManager *rManager, string routeName)
|
||||
{
|
||||
for(int i = 1; i <= 5; i++)
|
||||
{
|
||||
cout << "Awaiting file saving " << i << endl;
|
||||
if (rManager->HasSavedUserRoute(routeName))
|
||||
{
|
||||
cout << routeName << " found" << endl;
|
||||
return;
|
||||
}
|
||||
this_thread::sleep_for(chrono::seconds(1));
|
||||
}
|
||||
}
|
||||
|
||||
void awaitFileDeletion(RoutingManager *rManager, string routeName)
|
||||
{
|
||||
for(int i = 1; i <= 5; i++)
|
||||
{
|
||||
cout << "Awaiting file deletion " << i << endl;
|
||||
if (!rManager->HasSavedUserRoute(routeName))
|
||||
{
|
||||
cout << routeName << " deleted" << endl;
|
||||
return;
|
||||
}
|
||||
this_thread::sleep_for(chrono::seconds(1));
|
||||
}
|
||||
}
|
||||
|
||||
void awaitFileLoading(RoutingManager *rManager)
|
||||
{
|
||||
for(int i = 1; i <= 5; i++)
|
||||
{
|
||||
cout << "Awaiting file loading " << i << endl;
|
||||
if (rManager->GetRoutePointsCount() != 0)
|
||||
{
|
||||
cout << "Route loaded" << endl;
|
||||
return;
|
||||
}
|
||||
this_thread::sleep_for(chrono::seconds(1));
|
||||
}
|
||||
}
|
||||
|
||||
class TestDelegate : public RoutingManager::Delegate
|
||||
{
|
||||
void OnRouteFollow(routing::RouterType type) override
|
||||
{
|
||||
// Empty
|
||||
}
|
||||
|
||||
void RegisterCountryFilesOnRoute(std::shared_ptr<routing::NumMwmIds> ptr) const override
|
||||
{
|
||||
// Empty
|
||||
}
|
||||
};
|
||||
|
||||
UNIT_CLASS_TEST(Runner, user_routes_save_delete)
|
||||
{
|
||||
TestDelegate d = TestDelegate();
|
||||
TestDelegate & dRef = d;
|
||||
RoutingManager rManager(RM_CALLBACKS, dRef);
|
||||
BookmarkManager bmManager(BM_CALLBACKS);
|
||||
rManager.SetBookmarkManager(&bmManager);
|
||||
|
||||
rManager.AddRoutePoint(getRouteMarkStart());
|
||||
rManager.AddRoutePoint(getRouteMarkFinish());
|
||||
|
||||
TEST(RoutingManager::GetUserRouteNames().empty(),("User routes found before test start"));
|
||||
|
||||
rManager.SaveUserRoutePoints(kTestRouteName1);
|
||||
awaitFileSaving(&rManager, kTestRouteName1);
|
||||
|
||||
TEST(rManager.HasSavedUserRoute(kTestRouteName1), ("Test route not found after saving it"));
|
||||
|
||||
rManager.DeleteUserRoute(kTestRouteName1);
|
||||
awaitFileDeletion(&rManager, kTestRouteName1);
|
||||
|
||||
TEST(!rManager.HasSavedUserRoute(kTestRouteName1), ("Test route found after deleting it"));
|
||||
}
|
||||
|
||||
UNIT_CLASS_TEST(Runner, user_routes_rename)
|
||||
{
|
||||
TestDelegate d = TestDelegate();
|
||||
TestDelegate & dRef = d;
|
||||
RoutingManager rManager(RM_CALLBACKS, dRef);
|
||||
BookmarkManager bmManager(BM_CALLBACKS);
|
||||
rManager.SetBookmarkManager(&bmManager);
|
||||
|
||||
rManager.AddRoutePoint(getRouteMarkStart());
|
||||
rManager.AddRoutePoint(getRouteMarkFinish());
|
||||
|
||||
TEST(RoutingManager::GetUserRouteNames().empty(),("User routes found before test start"));
|
||||
|
||||
rManager.SaveUserRoutePoints(kTestRouteName1);
|
||||
awaitFileSaving(&rManager, kTestRouteName1);
|
||||
|
||||
TEST(rManager.HasSavedUserRoute(kTestRouteName1), ("Test route 1 not found after saving it"));
|
||||
TEST(!rManager.HasSavedUserRoute(kTestRouteName2), ("Test route 2 found before naming it that"));
|
||||
|
||||
rManager.RenameUserRoute(kTestRouteName1, kTestRouteName2);
|
||||
awaitFileSaving(&rManager, kTestRouteName2);
|
||||
|
||||
TEST(!rManager.HasSavedUserRoute(kTestRouteName1), ("Test route 1 found after renaming it"));
|
||||
TEST(rManager.HasSavedUserRoute(kTestRouteName2), ("Test route 2 not found after naming it that"));
|
||||
|
||||
rManager.DeleteUserRoute(kTestRouteName2);
|
||||
awaitFileDeletion(&rManager, kTestRouteName2);
|
||||
|
||||
TEST(!rManager.HasSavedUserRoute(kTestRouteName1), ("Test route 1 found after deleting it"));
|
||||
TEST(!rManager.HasSavedUserRoute(kTestRouteName2), ("Test route 2 found after deleting it"));
|
||||
}
|
||||
|
||||
UNIT_CLASS_TEST(Runner, user_routes_list)
|
||||
{
|
||||
TestDelegate d = TestDelegate();
|
||||
TestDelegate & dRef = d;
|
||||
RoutingManager rManager(RM_CALLBACKS, dRef);
|
||||
BookmarkManager bmManager(BM_CALLBACKS);
|
||||
rManager.SetBookmarkManager(&bmManager);
|
||||
|
||||
rManager.AddRoutePoint(getRouteMarkStart());
|
||||
rManager.AddRoutePoint(getRouteMarkFinish());
|
||||
|
||||
TEST(RoutingManager::GetUserRouteNames().empty(),("User routes found before test start"));
|
||||
|
||||
rManager.SaveUserRoutePoints(kTestRouteName1);
|
||||
rManager.SaveUserRoutePoints(kTestRouteName2);
|
||||
awaitFileSaving(&rManager, kTestRouteName1);
|
||||
awaitFileSaving(&rManager, kTestRouteName2);
|
||||
|
||||
TEST(rManager.HasSavedUserRoute(kTestRouteName1), ("Test route 1 not found after saving it"));
|
||||
TEST(rManager.HasSavedUserRoute(kTestRouteName2), ("Test route 2 not found after saving it"));
|
||||
|
||||
auto routes = RoutingManager::GetUserRouteNames();
|
||||
|
||||
TEST_EQUAL(routes.size(), 2, ("Incorrect number of routes found"));
|
||||
|
||||
set<string> routesSet(routes.begin(), routes.end());
|
||||
|
||||
set<string> expectedRoutes;
|
||||
expectedRoutes.insert(kTestRouteName1);
|
||||
expectedRoutes.insert(kTestRouteName2);
|
||||
|
||||
TEST_EQUAL(routesSet, expectedRoutes, ("Unexpected route names found"));
|
||||
|
||||
rManager.DeleteUserRoute(kTestRouteName1);
|
||||
rManager.DeleteUserRoute(kTestRouteName2);
|
||||
awaitFileDeletion(&rManager, kTestRouteName1);
|
||||
awaitFileDeletion(&rManager, kTestRouteName2);
|
||||
|
||||
TEST(RoutingManager::GetUserRouteNames().empty(),("Found User Routes after deletion"));
|
||||
}
|
||||
|
||||
// TODO Solve problems regarding LoadRoutePoints' use of Platform::Thread::Gui, code inside it seems not to be running
|
||||
/*UNIT_CLASS_TEST(Runner, user_routes_load)
|
||||
{
|
||||
TestDelegate d = TestDelegate();
|
||||
TestDelegate & dRef = d;
|
||||
RoutingManager rManager(RM_CALLBACKS, dRef);
|
||||
BookmarkManager bmManager(BM_CALLBACKS);
|
||||
rManager.SetBookmarkManager(&bmManager);
|
||||
|
||||
rManager.AddRoutePoint(getRouteMarkStart());
|
||||
rManager.AddRoutePoint(getRouteMarkFinish());
|
||||
|
||||
TEST(RoutingManager::GetUserRouteNames().empty(),("User routes found before test start"));
|
||||
|
||||
rManager.SaveUserRoutePoints(kTestRouteName1);
|
||||
awaitFileSaving(&rManager, kTestRouteName1);
|
||||
|
||||
TEST(rManager.HasSavedUserRoute(kTestRouteName1), ("Test route not found after saving it"));
|
||||
|
||||
rManager.RemoveRoutePoints();
|
||||
|
||||
TEST(rManager.GetRoutePoints().empty(), ("Route points found before loading"));
|
||||
|
||||
rManager.LoadUserRoutePoints(nullptr, kTestRouteName1);
|
||||
awaitFileLoading(&rManager);
|
||||
|
||||
TEST_EQUAL(rManager.GetRoutePoints().size(), 2, ("Test route loaded incorrect number of points"));
|
||||
|
||||
for (const auto& point : rManager.GetRoutePoints())
|
||||
{
|
||||
if (point.m_pointType == RouteMarkType::Start)
|
||||
TEST_EQUAL(point.m_position, m2::PointD(0,0), ("Start point incorrect"));
|
||||
else if (point.m_pointType == RouteMarkType::Finish)
|
||||
TEST_EQUAL(point.m_position, m2::PointD(1,1), ("Finish point incorrect"));
|
||||
else
|
||||
TEST(false, ("Intermediate point found on a 2 point route"));
|
||||
}
|
||||
|
||||
rManager.DeleteUserRoute(kTestRouteName1);
|
||||
awaitFileDeletion(&rManager, kTestRouteName1);
|
||||
|
||||
TEST(!rManager.HasSavedUserRoute(kTestRouteName1), ("Test route found after deleting it"));
|
||||
}*/
|
||||
|
||||
} // namespace user_routes_test
|
|
@ -50,6 +50,8 @@ double const kRouteScaleMultiplier = 1.5;
|
|||
|
||||
string const kRoutePointsFile = "route_points.dat";
|
||||
|
||||
string const kUserRoutesFileExtension = ".usrdat";
|
||||
|
||||
uint32_t constexpr kInvalidTransactionId = 0;
|
||||
|
||||
void FillTurnsDistancesForRendering(vector<RouteSegment> const & segments,
|
||||
|
@ -109,7 +111,7 @@ RouteMarkData GetLastPassedPoint(BookmarkManager * bmManager, vector<RouteMarkDa
|
|||
return data;
|
||||
}
|
||||
|
||||
void SerializeRoutePoint(json_t * node, RouteMarkData const & data)
|
||||
void SerializeRoutePoint(json_t * node, RouteMarkData const & data, bool const keepReplaceWithMyPositionAfterRestart)
|
||||
{
|
||||
ASSERT(node != nullptr, ());
|
||||
ToJSONObject(*node, "type", static_cast<int>(data.m_pointType));
|
||||
|
@ -117,7 +119,8 @@ void SerializeRoutePoint(json_t * node, RouteMarkData const & data)
|
|||
ToJSONObject(*node, "subtitle", data.m_subTitle);
|
||||
ToJSONObject(*node, "x", data.m_position.x);
|
||||
ToJSONObject(*node, "y", data.m_position.y);
|
||||
ToJSONObject(*node, "replaceWithMyPosition", data.m_replaceWithMyPositionAfterRestart);
|
||||
ToJSONObject(*node, "replaceWithMyPosition",
|
||||
keepReplaceWithMyPositionAfterRestart ? data.m_replaceWithMyPositionAfterRestart : false);
|
||||
}
|
||||
|
||||
RouteMarkData DeserializeRoutePoint(json_t * node)
|
||||
|
@ -140,18 +143,18 @@ RouteMarkData DeserializeRoutePoint(json_t * node)
|
|||
return data;
|
||||
}
|
||||
|
||||
string SerializeRoutePoints(vector<RouteMarkData> const & points)
|
||||
string SerializeRoutePoints(vector<RouteMarkData> const & points, bool const keepReplaceWithMyPositionAfterRestart)
|
||||
{
|
||||
ASSERT_GREATER_OR_EQUAL(points.size(), 2, ());
|
||||
auto pointsNode = base::NewJSONArray();
|
||||
for (auto const & p : points)
|
||||
{
|
||||
auto pointNode = base::NewJSONObject();
|
||||
SerializeRoutePoint(pointNode.get(), p);
|
||||
SerializeRoutePoint(pointNode.get(), p, keepReplaceWithMyPositionAfterRestart);
|
||||
json_array_append_new(pointsNode.get(), pointNode.release());
|
||||
}
|
||||
unique_ptr<char, JSONFreeDeleter> buffer(
|
||||
json_dumps(pointsNode.get(), JSON_COMPACT));
|
||||
json_dumps(pointsNode.get(), JSON_COMPACT | JSON_ENSURE_ASCII));
|
||||
return string(buffer.get());
|
||||
}
|
||||
|
||||
|
@ -1356,31 +1359,56 @@ void RoutingManager::CancelRoutePointsTransaction(uint32_t transactionId)
|
|||
routePoints.AddRoutePoint(std::move(markData));
|
||||
}
|
||||
|
||||
bool RoutingManager::HasSavedUserRoute(string const routeName) const
|
||||
{
|
||||
return HasSavedRoutePoints(routeName + kUserRoutesFileExtension);
|
||||
}
|
||||
|
||||
|
||||
bool RoutingManager::HasSavedRoutePoints() const
|
||||
{
|
||||
auto const fileName = GetPlatform().SettingsPathForFile(kRoutePointsFile);
|
||||
return GetPlatform().IsFileExistsByFullPath(fileName);
|
||||
return HasSavedRoutePoints(kRoutePointsFile);
|
||||
}
|
||||
|
||||
bool RoutingManager::HasSavedRoutePoints(string const fileName) const
|
||||
{
|
||||
auto const filePath = GetPlatform().SettingsPathForFile(fileName);
|
||||
return GetPlatform().IsFileExistsByFullPath(filePath);
|
||||
}
|
||||
|
||||
void RoutingManager::LoadUserRoutePoints(LoadRouteHandler const & handler, string const routeName)
|
||||
{
|
||||
LoadRoutePoints(handler, routeName + kUserRoutesFileExtension, false);
|
||||
}
|
||||
|
||||
void RoutingManager::LoadRoutePoints(LoadRouteHandler const & handler)
|
||||
{
|
||||
GetPlatform().RunTask(Platform::Thread::File, [this, handler]()
|
||||
LoadRoutePoints(handler, kRoutePointsFile, true);
|
||||
}
|
||||
|
||||
void RoutingManager::LoadRoutePoints(LoadRouteHandler const & handler, string const & fileName, bool const deleteAfterLoading)
|
||||
{
|
||||
GetPlatform().RunTask(Platform::Thread::File, [this, handler, fileName, deleteAfterLoading]()
|
||||
{
|
||||
if (!HasSavedRoutePoints())
|
||||
if (!HasSavedRoutePoints(fileName))
|
||||
{
|
||||
if (handler)
|
||||
handler(false /* success */);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete file after loading.
|
||||
auto const fileName = GetPlatform().SettingsPathForFile(kRoutePointsFile);
|
||||
SCOPE_GUARD(routePointsFileGuard, bind(&FileWriter::DeleteFileX, cref(fileName)));
|
||||
auto const filePath = GetPlatform().SettingsPathForFile(fileName);
|
||||
// define a lambda to delete the file based on deleteAfterLoading
|
||||
auto conditionalDelete = [&]()
|
||||
{
|
||||
if (deleteAfterLoading) FileWriter::DeleteFileX(filePath);
|
||||
};
|
||||
SCOPE_GUARD(routePointsFileGuard, conditionalDelete);
|
||||
|
||||
string data;
|
||||
try
|
||||
{
|
||||
ReaderPtr<Reader>(GetPlatform().GetReader(fileName)).ReadAsString(data);
|
||||
ReaderPtr<Reader>(GetPlatform().GetReader(filePath)).ReadAsString(data);
|
||||
}
|
||||
catch (RootException const & ex)
|
||||
{
|
||||
|
@ -1437,22 +1465,31 @@ void RoutingManager::LoadRoutePoints(LoadRouteHandler const & handler)
|
|||
});
|
||||
}
|
||||
|
||||
void RoutingManager::SaveRoutePoints()
|
||||
void RoutingManager::SaveUserRoutePoints(string const routeName)
|
||||
{
|
||||
SaveRoutePoints(routeName + kUserRoutesFileExtension, false);
|
||||
}
|
||||
|
||||
void RoutingManager::SaveRoutePoints() {
|
||||
SaveRoutePoints(kRoutePointsFile, true);
|
||||
}
|
||||
|
||||
void RoutingManager::SaveRoutePoints(string const fileName, bool const keepReplaceWithMyPositionAfterRestart)
|
||||
{
|
||||
auto points = GetRoutePointsToSave();
|
||||
if (points.empty())
|
||||
{
|
||||
DeleteSavedRoutePoints();
|
||||
DeleteSavedRoutePoints(fileName);
|
||||
return;
|
||||
}
|
||||
|
||||
GetPlatform().RunTask(Platform::Thread::File, [points = std::move(points)]()
|
||||
GetPlatform().RunTask(Platform::Thread::File, [points = std::move(points), fileName, keepReplaceWithMyPositionAfterRestart]()
|
||||
{
|
||||
try
|
||||
{
|
||||
auto const fileName = GetPlatform().SettingsPathForFile(kRoutePointsFile);
|
||||
FileWriter writer(fileName);
|
||||
string const pointsData = SerializeRoutePoints(points);
|
||||
auto const filePath = GetPlatform().SettingsPathForFile(fileName);
|
||||
FileWriter writer(filePath);
|
||||
string const pointsData = SerializeRoutePoints(points, keepReplaceWithMyPositionAfterRestart);
|
||||
writer.Write(pointsData.c_str(), pointsData.length());
|
||||
}
|
||||
catch (RootException const & ex)
|
||||
|
@ -1501,18 +1538,69 @@ void RoutingManager::OnExtrapolatedLocationUpdate(location::GpsInfo const & info
|
|||
routeMatchingInfo);
|
||||
}
|
||||
|
||||
void RoutingManager::DeleteUserRoute(string const routeName)
|
||||
{
|
||||
DeleteSavedRoutePoints(routeName + kUserRoutesFileExtension);
|
||||
}
|
||||
|
||||
void RoutingManager::DeleteSavedRoutePoints()
|
||||
{
|
||||
if (!HasSavedRoutePoints())
|
||||
DeleteSavedRoutePoints(kRoutePointsFile);
|
||||
}
|
||||
|
||||
void RoutingManager::DeleteSavedRoutePoints(string const fileName)
|
||||
{
|
||||
if (!HasSavedRoutePoints(fileName))
|
||||
return;
|
||||
|
||||
GetPlatform().RunTask(Platform::Thread::File, []()
|
||||
GetPlatform().RunTask(Platform::Thread::File, [fileName = fileName]()
|
||||
{
|
||||
auto const fileName = GetPlatform().SettingsPathForFile(kRoutePointsFile);
|
||||
FileWriter::DeleteFileX(fileName);
|
||||
auto const filePath = GetPlatform().SettingsPathForFile(fileName);
|
||||
FileWriter::DeleteFileX(filePath);
|
||||
});
|
||||
}
|
||||
|
||||
void RoutingManager::RenameUserRoute(string const oldRouteName, string const newRouteName)
|
||||
{
|
||||
if (!HasSavedRoutePoints(oldRouteName + kUserRoutesFileExtension))
|
||||
return;
|
||||
|
||||
if (HasSavedRoutePoints(newRouteName + kUserRoutesFileExtension))
|
||||
return;
|
||||
|
||||
GetPlatform().RunTask(Platform::Thread::File, [oldFileName = oldRouteName + kUserRoutesFileExtension ,
|
||||
newFileName = newRouteName + kUserRoutesFileExtension]()
|
||||
{
|
||||
auto const oldPath = GetPlatform().SettingsPathForFile(oldFileName);
|
||||
auto const newPath = GetPlatform().SettingsPathForFile(newFileName);
|
||||
base::RenameFileX(oldPath, newPath);
|
||||
});
|
||||
}
|
||||
|
||||
// static
|
||||
vector<string> RoutingManager::GetUserRouteNames()
|
||||
{
|
||||
vector<string> routeFileNames;
|
||||
vector<string> routeNames;
|
||||
Platform::GetFilesByExt(GetPlatform().SettingsDir(), kUserRoutesFileExtension, routeFileNames);
|
||||
|
||||
for(const auto & name : routeFileNames)
|
||||
{
|
||||
size_t idx = name.rfind(kUserRoutesFileExtension);
|
||||
if (idx == string::npos)
|
||||
continue;
|
||||
routeNames.push_back(name.substr(0, idx)); // string without extension
|
||||
}
|
||||
auto compareFunc = [](string s1, string s2)
|
||||
{
|
||||
std::transform(s1.begin(), s1.end(), s1.begin(), ::toupper);
|
||||
std::transform(s2.begin(), s2.end(), s2.begin(), ::toupper);
|
||||
return s1.compare(s2) < 0;
|
||||
};
|
||||
std::sort(routeNames.begin(), routeNames.end(), compareFunc);
|
||||
return routeNames;
|
||||
}
|
||||
|
||||
void RoutingManager::UpdatePreviewMode()
|
||||
{
|
||||
SetSubroutesVisibility(false /* visible */);
|
||||
|
|
|
@ -304,16 +304,36 @@ public:
|
|||
void CancelRoutePointsTransaction(uint32_t transactionId);
|
||||
static uint32_t InvalidRoutePointsTransactionId();
|
||||
|
||||
/// \returns true if there are route points saved in file and false otherwise.
|
||||
/// \returns true if there is a user route saved with the given name and false otherwise.
|
||||
bool HasSavedUserRoute(std::string routeName) const;
|
||||
/// \returns true if there are route points saved in the default file and false otherwise.
|
||||
bool HasSavedRoutePoints() const;
|
||||
/// \brief It loads road points from file and delete file after loading.
|
||||
/// \returns true if there are route points saved in file and false otherwise.
|
||||
bool HasSavedRoutePoints(std::string fileName) const;
|
||||
/// The result of the loading will be sent via SafeCallback.
|
||||
using LoadRouteHandler = platform::SafeCallback<void(bool success)>;
|
||||
/// \brief It loads road points from file with a user routes file extension and keeps file after loading.
|
||||
void LoadUserRoutePoints(LoadRouteHandler const & handler, std::string routeName);
|
||||
/// \brief It loads road points from file and delete file after loading.
|
||||
void LoadRoutePoints(LoadRouteHandler const & handler);
|
||||
/// \brief It saves route points to file.
|
||||
/// \brief It loads road points from file and can be set to delete file after loading.
|
||||
void LoadRoutePoints(LoadRouteHandler const & handler, std::string const& fileName, bool deleteAfterLoading);
|
||||
/// \brief It saves route points to file with a user routes file extension
|
||||
void SaveUserRoutePoints(std::string routeName);
|
||||
/// \brief It saves route points to default file.
|
||||
void SaveRoutePoints();
|
||||
/// \brief It deletes file with saved route points if it exists.
|
||||
/// \brief It saves route points to file with the given name.
|
||||
void SaveRoutePoints(std::string fileName, bool keepReplaceWithMyPositionAfterRestart);
|
||||
/// \brief It deletes the user route with the given name if it exists.
|
||||
void DeleteUserRoute(std::string routeName);
|
||||
/// \brief It deletes the default file with saved route points if it exists.
|
||||
void DeleteSavedRoutePoints();
|
||||
/// \brief It deletes file with saved route points if it exists.
|
||||
void DeleteSavedRoutePoints(std::string fileName);
|
||||
/// \brief It renames a user route
|
||||
void RenameUserRoute(std::string oldRouteName, std::string newRouteName);
|
||||
/// \returns names of the files with a user routes file extension without the .usrdat file extension.
|
||||
static std::vector<std::string> GetUserRouteNames();
|
||||
|
||||
void UpdatePreviewMode();
|
||||
void CancelPreviewMode();
|
||||
|
|
Loading…
Add table
Reference in a new issue