From a6614dbf056e6ba4e73ac5d34668eeab2e025bb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Sun, 10 May 2026 16:15:59 +0200 Subject: [PATCH 001/140] ux: align Android UI with iOS client patterns Replace the side drawer + collapsible bottom-sheet pattern with a bottom navigation bar (4 tabs: Home, Peers, Resources, Settings) to mirror the iOS client's TabView. Sub-screens (Advanced, Profiles, Change Server, Troubleshoot, About) are reached from the Settings tab and use an iOS-style sectioned list layout. - New SettingsFragment (sectioned list mirroring iOSSettingsView) - Promote PeersFragment / NetworksFragment to top-level destinations; drop the modal BottomDialogFragment + PagerAdapter - Profile chip on Home opens a ProfilePickerSheet (one-tap switch + Manage profiles link), echoing the iOS ProfileBadge - Restyle AdvancedFragment + TroubleshootFragment as sectioned lists; Theme Mode now opens a bottom-sheet picker - Refresh menu icons to thinner outlined Material Symbols - NavigationRailView via layout-w960dp for large screens / TV - Toolbar hidden on top-level destinations; visible on sub-screens - Profile cards adopt PR #137 dark-mode contrast fix - Treat the empty-profile-state JSON read as a normal first-launch case in ProfileManagerWrapper instead of logging at error level --- .../java/io/netbird/client/MainActivity.java | 235 ++---- .../client/ui/advanced/AdvancedFragment.java | 100 +-- .../client/ui/advanced/ThemePickerSheet.java | 76 ++ .../client/ui/home/BottomDialogFragment.java | 185 ----- .../netbird/client/ui/home/HomeFragment.java | 89 +-- .../netbird/client/ui/home/PagerAdapter.java | 48 -- .../client/ui/home/ProfilePickerAdapter.java | 67 ++ .../client/ui/home/ProfilePickerSheet.java | 175 +++++ .../client/ui/settings/SettingsFragment.java | 99 +++ app/src/main/res/drawable/ic_add.xml | 9 + .../main/res/drawable/ic_arrow_drop_down.xml | 9 + app/src/main/res/drawable/ic_check.xml | 9 + .../main/res/drawable/ic_chevron_right.xml | 9 + app/src/main/res/drawable/ic_menu_about.xml | 4 +- .../main/res/drawable/ic_menu_advanced.xml | 4 +- .../res/drawable/ic_menu_change_server.xml | 4 +- app/src/main/res/drawable/ic_menu_docs.xml | 4 +- app/src/main/res/drawable/ic_menu_profile.xml | 8 +- .../res/drawable/ic_menu_troubleshoot.xml | 2 +- app/src/main/res/drawable/ic_nav_home.xml | 9 + app/src/main/res/drawable/ic_nav_networks.xml | 9 + app/src/main/res/drawable/ic_nav_peers.xml | 9 + app/src/main/res/drawable/ic_nav_settings.xml | 9 + app/src/main/res/drawable/ic_open_in_new.xml | 9 + .../main/res/drawable/ic_profile_avatar.xml | 11 + app/src/main/res/drawable/profile_chip_bg.xml | 17 + app/src/main/res/drawable/settings_row_bg.xml | 14 + app/src/main/res/drawable/sheet_row_bg.xml | 9 + .../main/res/layout-w960dp/activity_main.xml | 53 ++ app/src/main/res/layout/activity_main.xml | 83 +- app/src/main/res/layout/app_bar_main.xml | 26 - app/src/main/res/layout/content_main.xml | 20 - app/src/main/res/layout/fragment_about.xml | 2 +- app/src/main/res/layout/fragment_advanced.xml | 743 +++++++++--------- .../res/layout/fragment_bottom_dialog.xml | 66 -- app/src/main/res/layout/fragment_home.xml | 115 ++- app/src/main/res/layout/fragment_networks.xml | 7 + app/src/main/res/layout/fragment_peers.xml | 7 + app/src/main/res/layout/fragment_profiles.xml | 5 +- app/src/main/res/layout/fragment_server.xml | 4 +- app/src/main/res/layout/fragment_settings.xml | 302 +++++++ .../main/res/layout/fragment_troubleshoot.xml | 142 ++-- app/src/main/res/layout/list_item_profile.xml | 12 +- .../res/layout/list_item_profile_picker.xml | 41 + app/src/main/res/layout/list_item_setting.xml | 53 ++ .../res/layout/list_item_setting_divider.xml | 6 + .../res/layout/list_item_setting_section.xml | 13 + .../res/layout/list_item_setting_toggle.xml | 51 ++ .../res/layout/nav_custom_bottom_item.xml | 14 - app/src/main/res/layout/nav_header_main.xml | 25 - .../main/res/layout/sheet_profile_picker.xml | 110 +++ .../main/res/layout/sheet_theme_picker.xml | 118 +++ .../main/res/menu/activity_main_drawer.xml | 35 - app/src/main/res/menu/bottom_nav.xml | 24 + .../main/res/navigation/mobile_navigation.xml | 20 +- app/src/main/res/values-night/colors.xml | 4 + app/src/main/res/values-night/themes.xml | 7 +- app/src/main/res/values/colors.xml | 4 + app/src/main/res/values/dimens.xml | 2 + app/src/main/res/values/strings.xml | 34 +- app/src/main/res/values/themes.xml | 22 + .../client/tool/ProfileManagerWrapper.java | 10 +- 62 files changed, 2090 insertions(+), 1322 deletions(-) create mode 100644 app/src/main/java/io/netbird/client/ui/advanced/ThemePickerSheet.java delete mode 100644 app/src/main/java/io/netbird/client/ui/home/BottomDialogFragment.java delete mode 100644 app/src/main/java/io/netbird/client/ui/home/PagerAdapter.java create mode 100644 app/src/main/java/io/netbird/client/ui/home/ProfilePickerAdapter.java create mode 100644 app/src/main/java/io/netbird/client/ui/home/ProfilePickerSheet.java create mode 100644 app/src/main/java/io/netbird/client/ui/settings/SettingsFragment.java create mode 100644 app/src/main/res/drawable/ic_add.xml create mode 100644 app/src/main/res/drawable/ic_arrow_drop_down.xml create mode 100644 app/src/main/res/drawable/ic_check.xml create mode 100644 app/src/main/res/drawable/ic_chevron_right.xml create mode 100644 app/src/main/res/drawable/ic_nav_home.xml create mode 100644 app/src/main/res/drawable/ic_nav_networks.xml create mode 100644 app/src/main/res/drawable/ic_nav_peers.xml create mode 100644 app/src/main/res/drawable/ic_nav_settings.xml create mode 100644 app/src/main/res/drawable/ic_open_in_new.xml create mode 100644 app/src/main/res/drawable/ic_profile_avatar.xml create mode 100644 app/src/main/res/drawable/profile_chip_bg.xml create mode 100644 app/src/main/res/drawable/settings_row_bg.xml create mode 100644 app/src/main/res/drawable/sheet_row_bg.xml create mode 100644 app/src/main/res/layout-w960dp/activity_main.xml delete mode 100644 app/src/main/res/layout/app_bar_main.xml delete mode 100644 app/src/main/res/layout/content_main.xml delete mode 100644 app/src/main/res/layout/fragment_bottom_dialog.xml create mode 100644 app/src/main/res/layout/fragment_settings.xml create mode 100644 app/src/main/res/layout/list_item_profile_picker.xml create mode 100644 app/src/main/res/layout/list_item_setting.xml create mode 100644 app/src/main/res/layout/list_item_setting_divider.xml create mode 100644 app/src/main/res/layout/list_item_setting_section.xml create mode 100644 app/src/main/res/layout/list_item_setting_toggle.xml delete mode 100644 app/src/main/res/layout/nav_custom_bottom_item.xml delete mode 100644 app/src/main/res/layout/nav_header_main.xml create mode 100644 app/src/main/res/layout/sheet_profile_picker.xml create mode 100644 app/src/main/res/layout/sheet_theme_picker.xml delete mode 100644 app/src/main/res/menu/activity_main_drawer.xml create mode 100644 app/src/main/res/menu/bottom_nav.xml diff --git a/app/src/main/java/io/netbird/client/MainActivity.java b/app/src/main/java/io/netbird/client/MainActivity.java index c7fb0b54..524a87d8 100644 --- a/app/src/main/java/io/netbird/client/MainActivity.java +++ b/app/src/main/java/io/netbird/client/MainActivity.java @@ -12,29 +12,28 @@ import android.os.IBinder; import android.text.Html; import android.util.Log; -import android.view.KeyEvent; +import android.util.TypedValue; import android.view.Menu; -import android.view.MenuItem; import android.view.View; +import android.view.ViewGroup; import android.widget.TextView; import android.widget.Toast; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; -import com.google.android.material.navigation.NavigationView; +import com.google.android.material.navigation.NavigationBarView; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; -import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; import androidx.core.content.ContextCompat; -import androidx.core.view.GravityCompat; import androidx.navigation.NavController; import androidx.navigation.Navigation; import androidx.navigation.ui.AppBarConfiguration; import androidx.navigation.ui.NavigationUI; -import androidx.drawerlayout.widget.DrawerLayout; import androidx.appcompat.app.AppCompatActivity; import io.netbird.client.databinding.ActivityMainBinding; @@ -48,7 +47,7 @@ import io.netbird.gomobile.android.URLOpener; -public class MainActivity extends AppCompatActivity implements NavigationView.OnNavigationItemSelectedListener, ServiceAccessor, StateListenerRegistry { +public class MainActivity extends AppCompatActivity implements ServiceAccessor, StateListenerRegistry { private StateListAnimator stateAnim; @@ -103,7 +102,7 @@ protected void onCreate(Bundle savedInstanceState) { binding = ActivityMainBinding.inflate(getLayoutInflater()); setContentView(binding.getRoot()); - setSupportActionBar(binding.appBarMain.toolbar); + setSupportActionBar(binding.toolbar); isRunningOnTV = PlatformUtils.isAndroidTV(this); useDeviceCodeFlow = PlatformUtils.requiresDeviceCodeFlow(this); @@ -114,69 +113,36 @@ protected void onCreate(Bundle savedInstanceState) { Log.i(LOGTAG, "Running on ChromeOS - using device code flow for authentication"); } - setVersionText(); + NavigationBarView bottomNav = (NavigationBarView) binding.bottomNav; - DrawerLayout drawer = binding.drawerLayout; - NavigationView navigationView = binding.navView; - - // Set the listener for menu item selections - navigationView.setNavigationItemSelectedListener(this); - - // Update profile menu item with active profile name - updateProfileMenuItem(navigationView); - - // On TV, request focus when drawer opens so D-pad navigation works - if (isRunningOnTV) { - drawer.addDrawerListener(new DrawerLayout.SimpleDrawerListener() { - @Override - public void onDrawerOpened(View drawerView) { - // Request focus on the drawer when it opens - navigationView.postDelayed(() -> { - navigationView.setFocusable(true); - navigationView.setFocusableInTouchMode(false); - - if (!navigationView.requestFocus()) { - Log.d(LOGTAG, "NavigationView couldn't get focus, trying menu items"); - } - - // Try to find and focus the first visible menu item - View menuView = navigationView.getChildAt(0); - if (menuView != null) { - View firstFocusable = menuView.findFocus(); - if (firstFocusable == null) { - menuView.requestFocus(); - } - } - }, 100); // Delay to let drawer animation finish - } - - @Override - public void onDrawerClosed(View drawerView) { - // Return focus to main content when drawer closed - View mainContent = findViewById(R.id.nav_host_fragment_content_main); - if (mainContent != null) { - mainContent.requestFocus(); - } - } - }); - } - - // Passing each menu ID as a set of Ids because each - // menu should be considered as top level destinations. - mAppBarConfiguration = new AppBarConfiguration.Builder( - R.id.nav_home) - .setOpenableLayout(drawer) - .build(); + // All four bottom-nav destinations are top-level — no Up arrow on those. + final Set topLevelDestinations = new HashSet<>(); + topLevelDestinations.add(R.id.nav_home); + topLevelDestinations.add(R.id.nav_peers); + topLevelDestinations.add(R.id.nav_networks); + topLevelDestinations.add(R.id.nav_settings); + mAppBarConfiguration = new AppBarConfiguration.Builder(topLevelDestinations).build(); navController = Navigation.findNavController(this, R.id.nav_host_fragment_content_main); NavigationUI.setupActionBarWithNavController(this, navController, mAppBarConfiguration); + NavigationUI.setupWithNavController(bottomNav, navController); navController.addOnDestinationChangedListener((controller, destination, arguments) -> { - if (destination.getId() == R.id.nav_home) { + int destId = destination.getId(); + + // Hide bottom nav on first-launch onboarding so the screen is clean. + if (destId == R.id.firstInstallFragment) { + bottomNav.setVisibility(View.GONE); + } else { + bottomNav.setVisibility(View.VISIBLE); + } + + // Top-level destinations (Home, Peers, Networks, Settings) don't need a toolbar — + // bottom nav already identifies the screen. Sub-screens keep the toolbar with title + Up arrow. + boolean isTopLevel = topLevelDestinations.contains(destId); + setToolbarVisible(!isTopLevel); + + if (destId == R.id.nav_home) { removeToolbarShadow(); - // Update profile menu item when returning to home (e.g., after profile switch) - if (binding != null && binding.navView != null) { - updateProfileMenuItem(binding.navView); - } } else { resetToolbar(); } @@ -271,15 +237,6 @@ public void onStart() { startService(); } - @Override - protected void onResume() { - super.onResume(); - // Update profile menu item when returning to MainActivity - if (binding != null && binding.navView != null) { - updateProfileMenuItem(binding.navView); - } - } - @Override protected void onPause() { super.onPause(); @@ -321,23 +278,6 @@ public boolean onCreateOptionsMenu(Menu menu) { return true; } - @Override - public boolean onNavigationItemSelected(@NonNull MenuItem item) { - int id = item.getItemId(); - if (id == R.id.nav_docs) { - item.setCheckable(false); - binding.drawerLayout.closeDrawers(); - openDocs(); - return true; - } - - // Use NavigationUI which handles launchSingleTop and saveState/restoreState - // This prevents fragment recreation and preserves state when alternating between destinations - boolean isHandled = NavigationUI.onNavDestinationSelected(item, navController); - binding.drawerLayout.closeDrawers(); - return isHandled; - } - @Override public boolean onSupportNavigateUp() { NavController navController = Navigation.findNavController(this, R.id.nav_host_fragment_content_main); @@ -480,12 +420,6 @@ public void unregisterServiceStateListener(StateListener listener) { serviceStateListeners.remove(listener); } - private void openDocs() { - String url = "https://docs.netbird.io"; // Replace with the desired URL - Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); - startActivity(intent); - } - private void startService() { Log.i(LOGTAG, "start VPN service"); Intent intent = new Intent(this, VPNService.class); @@ -504,21 +438,43 @@ private void showFirstInstallFragment() { } } + private void setToolbarVisible(boolean visible) { + ViewGroup.LayoutParams lp = binding.toolbar.getLayoutParams(); + int targetHeight; + if (visible) { + TypedValue tv = new TypedValue(); + if (getTheme().resolveAttribute(android.R.attr.actionBarSize, tv, true)) { + targetHeight = TypedValue.complexToDimensionPixelSize(tv.data, getResources().getDisplayMetrics()); + } else { + targetHeight = (int) (56 * getResources().getDisplayMetrics().density); + } + } else { + targetHeight = 0; + } + if (lp.height == targetHeight) { + return; + } + lp.height = targetHeight; + binding.toolbar.setLayoutParams(lp); + // Ensure AppBarLayout re-measures itself so the content below shifts up correctly. + binding.appbar.requestLayout(); + } + private void removeToolbarShadow() { - stateAnim = binding.appBarMain.appbar.getStateListAnimator(); - binding.appBarMain.appbar.setStateListAnimator(null); - binding.appBarMain.appbar.setElevation(0f); - binding.appBarMain.toolbar.setElevation(0); - binding.appBarMain.toolbar.setBackground(new ColorDrawable(ContextCompat.getColor(this, R.color.nb_bg_home))); + stateAnim = binding.appbar.getStateListAnimator(); + binding.appbar.setStateListAnimator(null); + binding.appbar.setElevation(0f); + binding.toolbar.setElevation(0); + binding.toolbar.setBackground(new ColorDrawable(ContextCompat.getColor(this, R.color.nb_bg_home))); } private void resetToolbar() { if(stateAnim!=null) { - binding.appBarMain.appbar.setStateListAnimator(stateAnim); + binding.appbar.setStateListAnimator(stateAnim); } - binding.appBarMain.appbar.setElevation(10f); - binding.appBarMain.toolbar.setElevation(0); - binding.appBarMain.toolbar.setBackground(new ColorDrawable(ContextCompat.getColor(this, R.color.nb_bg))); + binding.appbar.setElevation(10f); + binding.toolbar.setElevation(0); + binding.toolbar.setBackground(new ColorDrawable(ContextCompat.getColor(this, R.color.nb_bg))); } private void showAlwaysOnDialog(Runnable onDismissAction) { @@ -542,19 +498,6 @@ private void showAlwaysOnDialog(Runnable onDismissAction) { alertDialog.show(); } - private void setVersionText() { - try { - try { - String packageName = getPackageName(); - String versionName = getPackageManager().getPackageInfo(packageName, 0).versionName; - binding.navVersion.setText(versionName); - } catch (Exception e) { - binding.navVersion.setText(""); - } - } catch (Exception e) { - } - } - ConnectionListener connectionListener = new ConnectionListener() { @Override public synchronized void onAddressChanged(String fqdn, String ip) { @@ -634,56 +577,4 @@ public void onError(String msg) { }); } }; - - private void updateProfileMenuItem(NavigationView navigationView) { - try { - // Get active profile from ProfileManager instead of reading file - io.netbird.client.tool.ProfileManagerWrapper profileManager = - new io.netbird.client.tool.ProfileManagerWrapper(this); - String activeProfile = profileManager.getActiveProfile(); - Menu menu = navigationView.getMenu(); - MenuItem profileItem = menu.findItem(R.id.nav_profiles); - if (profileItem != null && activeProfile != null) { - profileItem.setTitle(activeProfile); - } - } catch (Exception e) { - Log.e(LOGTAG, "Failed to update profile menu item", e); - } - } - - @Override - public boolean onKeyDown(int keyCode, KeyEvent event) { - if (!isRunningOnTV) { - return super.onKeyDown(keyCode, event); - } - else { - Log.d(LOGTAG, "Key pressed: " + keyCode + " (" + KeyEvent.keyCodeToString(keyCode) + "), repeat: " + event.getRepeatCount()); - - if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) { - boolean isOnHomeScreen = navController != null && - navController.getCurrentDestination() != null && - navController.getCurrentDestination().getId() == R.id.nav_home; - - if (event.getRepeatCount() == 0 && isOnHomeScreen && !binding.drawerLayout.isDrawerOpen(GravityCompat.START)) { - Toast.makeText(this, R.string.tv_menu_hint, Toast.LENGTH_SHORT).show(); - } - - // drawer is not selectable on Android 16+, so we open via a long press of the left d-pad button instead - if (event.getRepeatCount() > 0 && !binding.drawerLayout.isDrawerOpen(GravityCompat.START)) { - Log.d(LOGTAG, "Long press LEFT detected - opening drawer"); - binding.drawerLayout.openDrawer(GravityCompat.START); - binding.navView.requestFocus(); - return true; - } - } - - if (keyCode == KeyEvent.KEYCODE_BACK && binding.drawerLayout.isDrawerOpen(GravityCompat.START)) { - Log.d(LOGTAG, "Closing drawer with BACK"); - binding.drawerLayout.closeDrawer(GravityCompat.START); - return true; - } - } - - return super.onKeyDown(keyCode, event); - } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/netbird/client/ui/advanced/AdvancedFragment.java b/app/src/main/java/io/netbird/client/ui/advanced/AdvancedFragment.java index 3f1e2a7c..6488385f 100644 --- a/app/src/main/java/io/netbird/client/ui/advanced/AdvancedFragment.java +++ b/app/src/main/java/io/netbird/client/ui/advanced/AdvancedFragment.java @@ -7,7 +7,6 @@ import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.RadioGroup; import android.widget.TextView; import android.widget.Toast; @@ -17,13 +16,12 @@ import androidx.fragment.app.Fragment; import io.netbird.client.R; -import io.netbird.client.databinding.ComponentSwitchBinding; import io.netbird.client.databinding.FragmentAdvancedBinding; import io.netbird.client.tool.Preferences; import io.netbird.client.tool.ProfileManagerWrapper; -public class AdvancedFragment extends Fragment { +public class AdvancedFragment extends Fragment implements ThemePickerSheet.OnThemeChangedListener { private static final String hiddenKey = "********"; private static final String LOGTAG = "AdvancedFragment"; @@ -42,12 +40,9 @@ private void showReconnectionNeededWarningDialog() { alertDialog.show(); } - private void configureForceRelayConnectionSwitch(@NonNull ComponentSwitchBinding binding, @NonNull Preferences preferences) { - binding.switchTitle.setText(R.string.advanced_force_relay_conn); - binding.switchDescription.setText(R.string.advanced_force_relay_conn_desc); - - binding.switchControl.setChecked(preferences.isConnectionForceRelayed()); - binding.switchControl.setOnCheckedChangeListener((buttonView, isChecked) -> { + private void configureForceRelayConnectionSwitch(@NonNull Preferences preferences) { + binding.switchForceRelayConnection.setChecked(preferences.isConnectionForceRelayed()); + binding.switchForceRelayConnection.setOnCheckedChangeListener((buttonView, isChecked) -> { if (isChecked) { preferences.enableForcedRelayConnection(); } else { @@ -56,11 +51,8 @@ private void configureForceRelayConnectionSwitch(@NonNull ComponentSwitchBinding showReconnectionNeededWarningDialog(); }); - - // Make parent layout clickable to toggle switch (for TV remote) - binding.getRoot().setOnClickListener(v -> { - binding.switchControl.toggle(); - }); + + binding.layoutForceRelayConnection.setOnClickListener(v -> binding.switchForceRelayConnection.toggle()); } public View onCreateView(@NonNull LayoutInflater inflater, @@ -137,11 +129,8 @@ public View onCreateView(@NonNull LayoutInflater inflater, Toast.makeText(inflater.getContext(), "Error: " + e.toString(), Toast.LENGTH_SHORT).show(); } }); - - // Make parent layout clickable to toggle switch (for TV remote) - binding.layoutRosenpas.setOnClickListener(v -> { - binding.switchRosenpass.toggle(); - }); + + binding.layoutRosenpas.setOnClickListener(v -> binding.switchRosenpass.toggle()); binding.switchRosenpassPermissive.setOnCheckedChangeListener((buttonView, isChecked) -> { goPreferences.setRosenpassPermissive(isChecked); @@ -152,42 +141,34 @@ public View onCreateView(@NonNull LayoutInflater inflater, Toast.makeText(inflater.getContext(), "Error: " + e, Toast.LENGTH_SHORT).show(); } }); - - // Make parent layout clickable to toggle switch (for TV remote) - binding.layoutRosenpassPermissive.setOnClickListener(v -> { - binding.switchRosenpassPermissive.toggle(); - }); - configureForceRelayConnectionSwitch(binding.layoutForceRelayConnection, preferences); + binding.layoutRosenpassPermissive.setOnClickListener(v -> binding.switchRosenpassPermissive.toggle()); + + configureForceRelayConnectionSwitch(preferences); // Initialize engine config switches (your settings) initializeEngineConfigSwitches(); - // Theme-picker initialisieren + // Theme picker row SharedPreferences sharedPreferences = inflater.getContext().getSharedPreferences("settings", Context.MODE_PRIVATE); int themeMode = sharedPreferences.getInt("theme_mode", AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM); - RadioGroup radioGroup = binding.radioGroupTheme; - if (themeMode == AppCompatDelegate.MODE_NIGHT_NO) { - radioGroup.check(binding.radioThemeLight.getId()); - } else if (themeMode == AppCompatDelegate.MODE_NIGHT_YES) { - radioGroup.check(binding.radioThemeDark.getId()); - } else { - radioGroup.check(binding.radioThemeSystem.getId()); - } - radioGroup.setOnCheckedChangeListener((group, checkedId) -> { - int mode = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM; - if (checkedId == binding.radioThemeLight.getId()) { - mode = AppCompatDelegate.MODE_NIGHT_NO; - } else if (checkedId == binding.radioThemeDark.getId()) { - mode = AppCompatDelegate.MODE_NIGHT_YES; - } - sharedPreferences.edit().putInt("theme_mode", mode).apply(); - AppCompatDelegate.setDefaultNightMode(mode); + binding.themeValue.setText(ThemePickerSheet.labelFor(requireContext(), themeMode)); + + binding.rowTheme.setOnClickListener(v -> { + ThemePickerSheet sheet = new ThemePickerSheet(); + sheet.show(getChildFragmentManager(), "ThemePickerSheet"); }); return root; } + @Override + public void onThemeChanged(int mode) { + if (binding != null) { + binding.themeValue.setText(ThemePickerSheet.labelFor(requireContext(), mode)); + } + } + private void initializeEngineConfigSwitches() { try { // Load current values from config @@ -252,31 +233,14 @@ private void initializeEngineConfigSwitches() { Log.e(LOGTAG, "Failed to set block inbound", e); } }); - - // Make parent layouts clickable to toggle switches (for TV remote) - binding.layoutAllowSsh.setOnClickListener(v -> { - binding.switchAllowSsh.toggle(); - }); - - binding.layoutBlockInbound.setOnClickListener(v -> { - binding.switchBlockInbound.toggle(); - }); - - binding.layoutDisableClientRoutes.setOnClickListener(v -> { - binding.switchDisableClientRoutes.toggle(); - }); - - binding.layoutDisableServerRoutes.setOnClickListener(v -> { - binding.switchDisableServerRoutes.toggle(); - }); - - binding.layoutDisableDns.setOnClickListener(v -> { - binding.switchDisableDns.toggle(); - }); - - binding.layoutDisableFirewall.setOnClickListener(v -> { - binding.switchDisableFirewall.toggle(); - }); + + // Make parent rows clickable to toggle switches (for TV remote) + binding.layoutAllowSsh.setOnClickListener(v -> binding.switchAllowSsh.toggle()); + binding.layoutBlockInbound.setOnClickListener(v -> binding.switchBlockInbound.toggle()); + binding.layoutDisableClientRoutes.setOnClickListener(v -> binding.switchDisableClientRoutes.toggle()); + binding.layoutDisableServerRoutes.setOnClickListener(v -> binding.switchDisableServerRoutes.toggle()); + binding.layoutDisableDns.setOnClickListener(v -> binding.switchDisableDns.toggle()); + binding.layoutDisableFirewall.setOnClickListener(v -> binding.switchDisableFirewall.toggle()); } catch (Exception e) { Log.e(LOGTAG, "Failed to initialize engine config switches", e); diff --git a/app/src/main/java/io/netbird/client/ui/advanced/ThemePickerSheet.java b/app/src/main/java/io/netbird/client/ui/advanced/ThemePickerSheet.java new file mode 100644 index 00000000..02d5c65b --- /dev/null +++ b/app/src/main/java/io/netbird/client/ui/advanced/ThemePickerSheet.java @@ -0,0 +1,76 @@ +package io.netbird.client.ui.advanced; + +import android.content.SharedPreferences; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatDelegate; + +import com.google.android.material.bottomsheet.BottomSheetDialogFragment; + +import io.netbird.client.databinding.SheetThemePickerBinding; + +public class ThemePickerSheet extends BottomSheetDialogFragment { + + public interface OnThemeChangedListener { + void onThemeChanged(int mode); + } + + private SheetThemePickerBinding binding; + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + binding = SheetThemePickerBinding.inflate(inflater, container, false); + return binding.getRoot(); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + SharedPreferences prefs = requireContext().getSharedPreferences("settings", 0); + int currentMode = prefs.getInt("theme_mode", AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM); + renderChecks(currentMode); + + binding.themeRowSystem.setOnClickListener(v -> pick(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)); + binding.themeRowLight.setOnClickListener(v -> pick(AppCompatDelegate.MODE_NIGHT_NO)); + binding.themeRowDark.setOnClickListener(v -> pick(AppCompatDelegate.MODE_NIGHT_YES)); + } + + private void renderChecks(int mode) { + binding.themeCheckSystem.setVisibility(mode == AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM ? View.VISIBLE : View.INVISIBLE); + binding.themeCheckLight.setVisibility(mode == AppCompatDelegate.MODE_NIGHT_NO ? View.VISIBLE : View.INVISIBLE); + binding.themeCheckDark.setVisibility(mode == AppCompatDelegate.MODE_NIGHT_YES ? View.VISIBLE : View.INVISIBLE); + } + + private void pick(int mode) { + SharedPreferences prefs = requireContext().getSharedPreferences("settings", 0); + prefs.edit().putInt("theme_mode", mode).apply(); + AppCompatDelegate.setDefaultNightMode(mode); + if (getParentFragment() instanceof OnThemeChangedListener) { + ((OnThemeChangedListener) getParentFragment()).onThemeChanged(mode); + } + dismiss(); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; + } + + static String labelFor(@NonNull android.content.Context ctx, int mode) { + if (mode == AppCompatDelegate.MODE_NIGHT_NO) { + return ctx.getString(io.netbird.client.R.string.advanced_theme_light); + } + if (mode == AppCompatDelegate.MODE_NIGHT_YES) { + return ctx.getString(io.netbird.client.R.string.advanced_theme_dark); + } + return ctx.getString(io.netbird.client.R.string.advanced_theme_system); + } +} diff --git a/app/src/main/java/io/netbird/client/ui/home/BottomDialogFragment.java b/app/src/main/java/io/netbird/client/ui/home/BottomDialogFragment.java deleted file mode 100644 index a0ed1ceb..00000000 --- a/app/src/main/java/io/netbird/client/ui/home/BottomDialogFragment.java +++ /dev/null @@ -1,185 +0,0 @@ -package io.netbird.client.ui.home; - -import android.annotation.SuppressLint; -import android.app.Dialog; -import android.content.Context; -import android.graphics.Color; -import android.graphics.Insets; -import android.graphics.drawable.ColorDrawable; -import android.os.Build; -import android.os.Bundle; -import android.util.DisplayMetrics; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.view.WindowInsets; -import android.view.WindowMetrics; -import android.widget.FrameLayout; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; -import androidx.viewpager2.widget.ViewPager2; - -import com.google.android.material.bottomsheet.BottomSheetBehavior; -import com.google.android.material.bottomsheet.BottomSheetDialog; -import com.google.android.material.tabs.TabLayout; -import com.google.android.material.tabs.TabLayoutMediator; - - -import io.netbird.client.PlatformUtils; -import io.netbird.client.R; -import io.netbird.client.databinding.FragmentBottomDialogBinding; - - -public class BottomDialogFragment extends com.google.android.material.bottomsheet.BottomSheetDialogFragment { - - private FragmentBottomDialogBinding binding; - private boolean isRunningOnTV; - - @NonNull - @Override - public Dialog onCreateDialog(Bundle savedInstanceState) { - isRunningOnTV = PlatformUtils.isAndroidTV(requireContext()); - - // Apply transparent background theme to the dialog - BottomSheetDialog dialog = new BottomSheetDialog(requireContext(), R.style.BottomSheetDialogTheme); - dialog.setOnShowListener(dialogInterface -> { - // Check if the fragment is still attached to avoid IllegalStateException - if (getActivity() == null || !isAdded()) { - return; - } - - FrameLayout bottomSheet = dialog.findViewById(com.google.android.material.R.id.design_bottom_sheet); - if (bottomSheet != null) { - // Set the bottom sheet to be full screen - BottomSheetBehavior behavior = BottomSheetBehavior.from(bottomSheet); - - behavior.setFitToContents(false); - - behavior.setState(BottomSheetBehavior.STATE_EXPANDED); - behavior.setSkipCollapsed(true); - behavior.setPeekHeight(0); - - ViewGroup.LayoutParams params = bottomSheet.getLayoutParams(); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - WindowMetrics windowMetrics = requireActivity().getWindowManager().getCurrentWindowMetrics(); - WindowInsets windowInsets = windowMetrics.getWindowInsets(); - Insets insets = windowInsets.getInsetsIgnoringVisibility( - WindowInsets.Type.systemBars() - ); - params.height = windowMetrics.getBounds().height() - insets.top; - } else { - DisplayMetrics displayMetrics = new DisplayMetrics(); - requireActivity().getWindowManager().getDefaultDisplay().getMetrics(displayMetrics); - int screenHeight = displayMetrics.heightPixels; - params.height = (int) (screenHeight * 0.91f); - } - - bottomSheet.setLayoutParams(params); - - // Set the background to transparent - bottomSheet.setBackground(new ColorDrawable(Color.TRANSPARENT)); - bottomSheet.requestLayout(); - - // Restore dim to create overlay and enable proper touch handling - if (dialog.getWindow() != null) { - dialog.getWindow().setDimAmount(0.5f); - } - } - }); - - return dialog; - } - - @Override - public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - binding = FragmentBottomDialogBinding.inflate(inflater, container, false); - return binding.getRoot(); - } - - - @SuppressLint("NonConstantResourceId") - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - - // Set rounded corners background on your sheet content - view.setBackground(ContextCompat.getDrawable(requireContext(), R.drawable.rounded_top_corners)); - - // Hide close button on TV (users will use the back button to exit) - if (isRunningOnTV) { - binding.buttonClose.setVisibility(View.GONE); - - // Make the root view focusable to prevent focus from going to elements behind it - view.setFocusable(true); - view.setFocusableInTouchMode(false); - - binding.separator.setFocusable(false); - binding.separator.setFocusableInTouchMode(false); - } - - binding.buttonClose.setOnClickListener(v -> dismiss()); - - setupViewPager(); - } - - private void setupViewPager() { - ViewPager2 viewPager = binding.peersViewPager; - TabLayout tabLayout = binding.peersTabLayout; - - PagerAdapter adapter = new PagerAdapter(this, isRunningOnTV); - viewPager.setAdapter(adapter); - - new TabLayoutMediator(tabLayout, viewPager, (tab, position) -> { - switch (position) { - case 0: - tab.setText("Peers"); - tab.setIcon(R.drawable.peers); - break; - case 1: - tab.setText("Networks"); - tab.setIcon(R.drawable.networks); - break; - default: - tab.setText("Tab " + position); - } - }).attach(); - - if (isRunningOnTV) { - viewPager.setFocusable(false); - viewPager.setFocusableInTouchMode(false); - - tabLayout.setFocusable(true); - tabLayout.setFocusableInTouchMode(false); - - tabLayout.postDelayed(() -> { - if (!tabLayout.requestFocus()) { - for (int i = 0; i < tabLayout.getTabCount(); i++) { - View tabView = tabLayout.getTabAt(i).view; - if (tabView != null && tabView.requestFocus()) { - break; - } - } - } - }, 100); - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - WindowMetrics windowMetrics = requireActivity().getWindowManager().getCurrentWindowMetrics(); - WindowInsets windowInsets = windowMetrics.getWindowInsets(); - Insets insets = windowInsets.getInsetsIgnoringVisibility(WindowInsets.Type.systemBars()); - tabLayout.setPadding( - tabLayout.getPaddingLeft(), - tabLayout.getPaddingTop(), - tabLayout.getPaddingRight(), - insets.bottom - ); - } - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - binding = null; - } -} \ No newline at end of file diff --git a/app/src/main/java/io/netbird/client/ui/home/HomeFragment.java b/app/src/main/java/io/netbird/client/ui/home/HomeFragment.java index e91a725b..794e011e 100644 --- a/app/src/main/java/io/netbird/client/ui/home/HomeFragment.java +++ b/app/src/main/java/io/netbird/client/ui/home/HomeFragment.java @@ -3,11 +3,10 @@ import android.content.Context; import android.content.res.Configuration; import android.os.Bundle; -import android.text.Html; +import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.FrameLayout; import android.widget.TextView; import androidx.annotation.NonNull; @@ -15,19 +14,14 @@ import com.airbnb.lottie.LottieAnimationView; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - import io.netbird.client.PlatformUtils; -import io.netbird.client.R; import io.netbird.client.ServiceAccessor; import io.netbird.client.StateListener; import io.netbird.client.StateListenerRegistry; import io.netbird.client.databinding.FragmentHomeBinding; -import io.netbird.gomobile.android.PeerInfo; -import io.netbird.gomobile.android.PeerInfoArray; +import io.netbird.client.tool.ProfileManagerWrapper; -public class HomeFragment extends Fragment implements StateListener { +public class HomeFragment extends Fragment implements StateListener, ProfilePickerSheet.OnProfileSwitchedListener { private FragmentHomeBinding binding; private ServiceAccessor serviceAccessor; @@ -40,10 +34,6 @@ public class HomeFragment extends Fragment implements StateListener { private ButtonAnimation buttonAnimation; private boolean isConnected; - // serializes peer-list refreshes off the UI thread; serviceAccessor.getPeersList() - // is a JNI call into Go that can take seconds during engine bootstrap/teardown - private ExecutorService refreshExecutor; - @Override public void onAttach(@NonNull Context context) { super.onAttach(context); @@ -68,8 +58,6 @@ public View onCreateView(@NonNull LayoutInflater inflater, textNetworkAddress = binding.textNetworkAddress; TextView textConnStatus = binding.textConnectionStatus; - updatePeerCount(0,0); - buttonConnect = binding.btnConnect; // Try to load the correct Lottie file for dark/light mode, fallback to light if dark is missing boolean isDarkMode = (requireContext().getResources().getConfiguration().uiMode @@ -104,16 +92,13 @@ public View onCreateView(@NonNull LayoutInflater inflater, } }); - // peers button - FrameLayout openPanelCardView = binding.peersBtn; - openPanelCardView.setOnClickListener(v -> { - // Clear focus from the button to remove highlight - v.clearFocus(); - - BottomDialogFragment fragment = new BottomDialogFragment(); - fragment.show(getParentFragmentManager(), fragment.getTag()); + binding.profileChip.setOnClickListener(v -> { + ProfilePickerSheet sheet = new ProfilePickerSheet(); + sheet.show(getChildFragmentManager(), "ProfilePickerSheet"); }); + updateProfileChip(); + if (PlatformUtils.isAndroidTV(requireContext())) { root.postDelayed(() -> { if (buttonConnect != null && buttonConnect.isEnabled()) { @@ -122,22 +107,38 @@ public View onCreateView(@NonNull LayoutInflater inflater, }, 200); } - refreshExecutor = Executors.newSingleThreadExecutor(); stateListenerRegistry.registerServiceStateListener(this); return root; } + @Override + public void onResume() { + super.onResume(); + updateProfileChip(); + } + + @Override + public void onProfileSwitched(String newActiveName) { + updateProfileChip(); + } + + private void updateProfileChip() { + if (binding == null) return; + try { + ProfileManagerWrapper profileManager = new ProfileManagerWrapper(requireContext()); + String activeProfile = profileManager.getActiveProfile(); + binding.profileChipText.setText(activeProfile != null ? activeProfile : ""); + } catch (Exception e) { + Log.e("HomeFragment", "Failed to read active profile", e); + binding.profileChipText.setText(""); + } + } + @Override public void onDestroyView() { super.onDestroyView(); buttonAnimation.destroy(); stateListenerRegistry.unregisterServiceStateListener(this); - if (refreshExecutor != null) { - refreshExecutor.shutdown(); - refreshExecutor = null; - } - FrameLayout openPanelCardView = binding.peersBtn; - openPanelCardView.setOnClickListener(null); binding = null; } @@ -193,7 +194,6 @@ public void onDisconnected() { buttonAnimation.disconnected(); buttonConnect.setEnabled(true); }); - updatePeerCount(0, 0); } @Override @@ -203,29 +203,6 @@ public void onDisconnecting() { @Override public void onPeersListChanged(long numberOfPeers) { - ExecutorService executor = refreshExecutor; - if (executor == null) { - return; - } - executor.execute(() -> { - PeerInfoArray peersList = serviceAccessor.getPeersList(); - int connected = 0; - for (int i = 0; i < peersList.size(); i++) { - PeerInfo peer = peersList.get(i); - if(Status.fromLong(peer.getConnStatus()) == Status.CONNECTED) { - connected++; - } - } - updatePeerCount(connected, peersList.size()); - }); - } - - private void updatePeerCount(int connectedPeers, long totalPeers) { - if(binding==null) return; - TextView textPeersCount = binding.textOpenPanel; - String text = getString(R.string.peers_connected, connectedPeers, totalPeers); - textPeersCount.post(() -> - textPeersCount.setText(Html.fromHtml(text, Html.FROM_HTML_MODE_LEGACY)) - ); + // peer count badge moved to bottom navigation; intentionally noop here } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/netbird/client/ui/home/PagerAdapter.java b/app/src/main/java/io/netbird/client/ui/home/PagerAdapter.java deleted file mode 100644 index 8f2cf337..00000000 --- a/app/src/main/java/io/netbird/client/ui/home/PagerAdapter.java +++ /dev/null @@ -1,48 +0,0 @@ -package io.netbird.client.ui.home; - -import android.os.Bundle; - -import androidx.annotation.NonNull; -import androidx.fragment.app.Fragment; -import androidx.viewpager2.adapter.FragmentStateAdapter; - -public class PagerAdapter extends FragmentStateAdapter { - - private boolean isRunningOnTV; - - private static final String ARG_IS_RUNNING_ON_TV = "isRunningOnTV"; - - public PagerAdapter(@NonNull Fragment fragment, boolean isRunningOnTV) { - super(fragment); - this.isRunningOnTV = isRunningOnTV; - } - - @NonNull - @Override - public Fragment createFragment(int position) { - Fragment fragment; - switch (position) { - case 0: - fragment = new PeersFragment(); - break; - case 1: - fragment = new NetworksFragment(); - break; - default: - fragment = new PeersFragment(); - break; - } - - // Pass TV flag to fragments - Bundle args = new Bundle(); - args.putBoolean(ARG_IS_RUNNING_ON_TV, isRunningOnTV); - fragment.setArguments(args); - - return fragment; - } - - @Override - public int getItemCount() { - return 2; - } -} \ No newline at end of file diff --git a/app/src/main/java/io/netbird/client/ui/home/ProfilePickerAdapter.java b/app/src/main/java/io/netbird/client/ui/home/ProfilePickerAdapter.java new file mode 100644 index 00000000..d3665e37 --- /dev/null +++ b/app/src/main/java/io/netbird/client/ui/home/ProfilePickerAdapter.java @@ -0,0 +1,67 @@ +package io.netbird.client.ui.home; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.List; + +import io.netbird.client.R; +import io.netbird.client.tool.Profile; + +class ProfilePickerAdapter extends RecyclerView.Adapter { + + interface OnPickListener { + void onPick(Profile profile); + } + + private final List profiles; + private final OnPickListener listener; + + ProfilePickerAdapter(List profiles, OnPickListener listener) { + this.profiles = profiles; + this.listener = listener; + } + + @NonNull + @Override + public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.list_item_profile_picker, parent, false); + return new VH(view); + } + + @Override + public void onBindViewHolder(@NonNull VH holder, int position) { + Profile profile = profiles.get(position); + holder.name.setText(profile.getName()); + holder.check.setVisibility(profile.isActive() ? View.VISIBLE : View.INVISIBLE); + holder.dot.setBackgroundResource(profile.isActive() + ? R.drawable.peer_status_connected + : R.drawable.peer_status_disconnected); + holder.itemView.setOnClickListener(v -> listener.onPick(profile)); + } + + @Override + public int getItemCount() { + return profiles.size(); + } + + static class VH extends RecyclerView.ViewHolder { + final View dot; + final TextView name; + final ImageView check; + + VH(@NonNull View itemView) { + super(itemView); + dot = itemView.findViewById(R.id.profile_picker_dot); + name = itemView.findViewById(R.id.profile_picker_name); + check = itemView.findViewById(R.id.profile_picker_check); + } + } +} diff --git a/app/src/main/java/io/netbird/client/ui/home/ProfilePickerSheet.java b/app/src/main/java/io/netbird/client/ui/home/ProfilePickerSheet.java new file mode 100644 index 00000000..e40268c9 --- /dev/null +++ b/app/src/main/java/io/netbird/client/ui/home/ProfilePickerSheet.java @@ -0,0 +1,175 @@ +package io.netbird.client.ui.home; + +import android.app.AlertDialog; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.navigation.fragment.NavHostFragment; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.bottomsheet.BottomSheetDialogFragment; + +import java.util.ArrayList; +import java.util.List; + +import io.netbird.client.R; +import io.netbird.client.databinding.SheetProfilePickerBinding; +import io.netbird.client.tool.Profile; +import io.netbird.client.tool.ProfileManagerWrapper; + +public class ProfilePickerSheet extends BottomSheetDialogFragment { + + public interface OnProfileSwitchedListener { + void onProfileSwitched(String newActiveName); + } + + private static final String TAG = "ProfilePickerSheet"; + + private SheetProfilePickerBinding binding; + private ProfileManagerWrapper profileManager; + private final List profiles = new ArrayList<>(); + private ProfilePickerAdapter adapter; + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + binding = SheetProfilePickerBinding.inflate(inflater, container, false); + return binding.getRoot(); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + profileManager = new ProfileManagerWrapper(requireContext()); + + adapter = new ProfilePickerAdapter(profiles, this::handlePickProfile); + RecyclerView list = binding.profilePickerList; + list.setLayoutManager(new LinearLayoutManager(requireContext())); + list.setAdapter(adapter); + + binding.profilePickerAdd.setOnClickListener(v -> showAddDialog()); + + binding.profilePickerManage.setOnClickListener(v -> { + dismiss(); + // Navigate the host activity's NavController to the manage-profiles destination. + NavHostFragment.findNavController(requireParentFragment()) + .navigate(R.id.nav_profiles); + }); + + loadProfiles(); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; + } + + private void loadProfiles() { + profiles.clear(); + profiles.addAll(profileManager.listProfiles()); + if (adapter != null) { + adapter.notifyDataSetChanged(); + } + } + + private void handlePickProfile(Profile profile) { + if (profile.isActive()) { + dismiss(); + return; + } + try { + profileManager.switchProfile(profile.getName()); + Toast.makeText(requireContext(), + getString(R.string.profiles_success_switched, profile.getName()), + Toast.LENGTH_SHORT).show(); + + if (getParentFragment() instanceof OnProfileSwitchedListener) { + ((OnProfileSwitchedListener) getParentFragment()).onProfileSwitched(profile.getName()); + } + } catch (Exception e) { + Log.e(TAG, "Failed to switch profile", e); + Toast.makeText(requireContext(), + "Failed to switch profile: " + e.getMessage(), + Toast.LENGTH_SHORT).show(); + } + dismiss(); + } + + private void showAddDialog() { + View dialogView = LayoutInflater.from(requireContext()) + .inflate(R.layout.dialog_simple_edit_text, null); + + TextView txtTitle = dialogView.findViewById(R.id.text_title_dialog); + txtTitle.setText(R.string.profiles_dialog_add_title); + + TextView txtMessage = dialogView.findViewById(R.id.text_label_dialog); + txtMessage.setText(R.string.profiles_dialog_add_message); + + EditText input = dialogView.findViewById(R.id.edit_text_dialog); + input.setHint(R.string.profiles_dialog_add_hint); + + AlertDialog dialog = new AlertDialog.Builder(requireContext(), R.style.AlertDialogTheme) + .setView(dialogView) + .create(); + + dialogView.findViewById(R.id.btn_cancel_dialog).setOnClickListener(v -> dialog.dismiss()); + dialogView.findViewById(R.id.btn_ok_dialog).setOnClickListener(v -> { + String profileName = input.getText().toString().trim(); + if (profileName.isEmpty()) { + Toast.makeText(requireContext(), R.string.profiles_error_empty_name, Toast.LENGTH_SHORT).show(); + return; + } + String sanitized = sanitizeProfileName(profileName); + if (sanitized.isEmpty()) { + Toast.makeText(requireContext(), + "Profile name must contain at least one letter, digit, underscore or hyphen", + Toast.LENGTH_LONG).show(); + return; + } + try { + profileManager.addProfile(profileName); + Toast.makeText(requireContext(), + getString(R.string.profiles_success_added, profileName), + Toast.LENGTH_SHORT).show(); + loadProfiles(); + } catch (Exception e) { + Log.e(TAG, "Failed to add profile", e); + String msg = e.getMessage(); + if (msg != null && msg.contains("already exists")) { + Toast.makeText(requireContext(), + getString(R.string.profiles_error_already_exists, profileName), + Toast.LENGTH_SHORT).show(); + } else { + Toast.makeText(requireContext(), + "Failed to add profile: " + msg, + Toast.LENGTH_SHORT).show(); + } + } + dialog.dismiss(); + }); + + dialog.show(); + } + + private static String sanitizeProfileName(String name) { + StringBuilder result = new StringBuilder(); + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + if (Character.isLetterOrDigit(c) || c == '_' || c == '-') { + result.append(c); + } + } + return result.toString(); + } +} diff --git a/app/src/main/java/io/netbird/client/ui/settings/SettingsFragment.java b/app/src/main/java/io/netbird/client/ui/settings/SettingsFragment.java new file mode 100644 index 00000000..ea0f268e --- /dev/null +++ b/app/src/main/java/io/netbird/client/ui/settings/SettingsFragment.java @@ -0,0 +1,99 @@ +package io.netbird.client.ui.settings; + +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.navigation.NavController; +import androidx.navigation.fragment.NavHostFragment; + +import io.netbird.client.R; +import io.netbird.client.databinding.FragmentSettingsBinding; +import io.netbird.client.tool.ProfileManagerWrapper; + +public class SettingsFragment extends Fragment { + + private static final String LOGTAG = "NBSettingsFragment"; + private static final String DOCS_URL = "https://docs.netbird.io"; + + private FragmentSettingsBinding binding; + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + binding = FragmentSettingsBinding.inflate(inflater, container, false); + return binding.getRoot(); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + NavController navController = NavHostFragment.findNavController(this); + + binding.rowProfiles.setOnClickListener(v -> + navController.navigate(R.id.nav_profiles)); + + binding.rowChangeServer.setOnClickListener(v -> + navController.navigate(R.id.nav_change_server)); + + binding.rowAdvanced.setOnClickListener(v -> + navController.navigate(R.id.nav_advanced)); + + binding.rowTroubleshoot.setOnClickListener(v -> + navController.navigate(R.id.nav_troubleshoot)); + + binding.rowAbout.setOnClickListener(v -> + navController.navigate(R.id.nav_about)); + + binding.rowDocumentation.setOnClickListener(v -> { + Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(DOCS_URL)); + startActivity(intent); + }); + + setVersionText(); + } + + @Override + public void onResume() { + super.onResume(); + updateActiveProfileName(); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; + } + + private void updateActiveProfileName() { + if (binding == null) return; + try { + ProfileManagerWrapper profileManager = new ProfileManagerWrapper(requireContext()); + String activeProfile = profileManager.getActiveProfile(); + binding.activeProfileName.setText(activeProfile != null ? activeProfile : ""); + } catch (Exception e) { + Log.e(LOGTAG, "Failed to read active profile", e); + binding.activeProfileName.setText(""); + } + } + + private void setVersionText() { + if (binding == null) return; + try { + String packageName = requireContext().getPackageName(); + String versionName = requireContext().getPackageManager() + .getPackageInfo(packageName, 0).versionName; + binding.versionText.setText(getString(R.string.about_version) + versionName); + } catch (Exception e) { + binding.versionText.setText(""); + } + } +} diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml new file mode 100644 index 00000000..38d89a46 --- /dev/null +++ b/app/src/main/res/drawable/ic_add.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_drop_down.xml b/app/src/main/res/drawable/ic_arrow_drop_down.xml new file mode 100644 index 00000000..4a0c0a73 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_drop_down.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_check.xml b/app/src/main/res/drawable/ic_check.xml new file mode 100644 index 00000000..a82e4ad8 --- /dev/null +++ b/app/src/main/res/drawable/ic_check.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_chevron_right.xml b/app/src/main/res/drawable/ic_chevron_right.xml new file mode 100644 index 00000000..fe7ffeb0 --- /dev/null +++ b/app/src/main/res/drawable/ic_chevron_right.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_menu_about.xml b/app/src/main/res/drawable/ic_menu_about.xml index 8f674e76..23f72e3c 100644 --- a/app/src/main/res/drawable/ic_menu_about.xml +++ b/app/src/main/res/drawable/ic_menu_about.xml @@ -4,8 +4,8 @@ android:viewportWidth="24" android:viewportHeight="24" android:tint="?attr/colorControlNormal"> - + + android:pathData="M11,7h2v2h-2zM11,11h2v6h-2zM12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8z"/> diff --git a/app/src/main/res/drawable/ic_menu_advanced.xml b/app/src/main/res/drawable/ic_menu_advanced.xml index 37563dd5..21651e70 100644 --- a/app/src/main/res/drawable/ic_menu_advanced.xml +++ b/app/src/main/res/drawable/ic_menu_advanced.xml @@ -4,8 +4,8 @@ android:viewportWidth="24" android:viewportHeight="24" android:tint="?attr/colorControlNormal"> - + + android:pathData="M3,17v2h6v-2H3zM3,5v2h10V5H3zM13,21v-2h8v-2h-8v-2h-2v6h2zM7,9v2H3v2h4v2h2V9H7zM21,13v-2H11v2h10zM15,9h2V7h4V5h-4V3h-2v6z"/> diff --git a/app/src/main/res/drawable/ic_menu_change_server.xml b/app/src/main/res/drawable/ic_menu_change_server.xml index b0bc26ea..e6ee0b1e 100644 --- a/app/src/main/res/drawable/ic_menu_change_server.xml +++ b/app/src/main/res/drawable/ic_menu_change_server.xml @@ -4,8 +4,8 @@ android:viewportWidth="24" android:viewportHeight="24" android:tint="?attr/colorControlNormal"> - + + android:pathData="M19,15v4H5v-4H19M19,13H5c-1.1,0 -2,0.9 -2,2v4c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2v-4c0,-1.1 -0.9,-2 -2,-2L19,13zM7,18.5c-0.83,0 -1.5,-0.67 -1.5,-1.5s0.67,-1.5 1.5,-1.5 1.5,0.67 1.5,1.5S7.83,18.5 7,18.5zM19,5v4H5L5,5h14M19,3H5C3.9,3 3,3.9 3,5v4c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V5C21,3.9 20.1,3 19,3zM7,8.5C6.17,8.5 5.5,7.83 5.5,7s0.67,-1.5 1.5,-1.5S8.5,6.17 8.5,7 7.83,8.5 7,8.5z"/> diff --git a/app/src/main/res/drawable/ic_menu_docs.xml b/app/src/main/res/drawable/ic_menu_docs.xml index 7173201c..1d4fc890 100644 --- a/app/src/main/res/drawable/ic_menu_docs.xml +++ b/app/src/main/res/drawable/ic_menu_docs.xml @@ -4,8 +4,8 @@ android:viewportWidth="24" android:viewportHeight="24" android:tint="?attr/colorControlNormal"> - + + android:pathData="M14,2H6C4.9,2 4,2.9 4,4v16c0,1.1 0.89,2 1.99,2H18c1.1,0 2,-0.9 2,-2V8L14,2zM18,20H6V4h7v5h5V20zM8,15h8v2H8zM8,11h8v2H8z"/> diff --git a/app/src/main/res/drawable/ic_menu_profile.xml b/app/src/main/res/drawable/ic_menu_profile.xml index 114cef05..524bed40 100644 --- a/app/src/main/res/drawable/ic_menu_profile.xml +++ b/app/src/main/res/drawable/ic_menu_profile.xml @@ -2,8 +2,10 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24"> + android:viewportHeight="24" + android:tint="?attr/colorControlNormal"> + + android:fillColor="#000000" + android:pathData="M12,5.9c1.16,0 2.1,0.94 2.1,2.1s-0.94,2.1 -2.1,2.1S9.9,9.16 9.9,8s0.94,-2.1 2.1,-2.1m0,9c2.97,0 6.1,1.46 6.1,2.1v1.1H5.9V17c0,-0.64 3.13,-2.1 6.1,-2.1M12,4C9.79,4 8,5.79 8,8s1.79,4 4,4 4,-1.79 4,-4 -1.79,-4 -4,-4zM12,13c-2.67,0 -8,1.34 -8,4v3h16v-3c0,-2.66 -5.33,-4 -8,-4z"/> diff --git a/app/src/main/res/drawable/ic_menu_troubleshoot.xml b/app/src/main/res/drawable/ic_menu_troubleshoot.xml index 9856cc1e..45fac82c 100644 --- a/app/src/main/res/drawable/ic_menu_troubleshoot.xml +++ b/app/src/main/res/drawable/ic_menu_troubleshoot.xml @@ -7,5 +7,5 @@ + android:pathData="M20,8h-2.81c-0.45,-0.78 -1.07,-1.45 -1.82,-1.96L17,4.41 15.59,3l-2.17,2.17C12.96,5.06 12.49,5 12,5s-0.96,0.06 -1.41,0.17L8.41,3 7,4.41l1.62,1.63C7.88,6.55 7.26,7.22 6.81,8H4v2h2.09C6.04,10.33 6,10.66 6,11v1H4v2h2v1c0,0.34 0.04,0.67 0.09,1H4v2h2.81c1.04,1.79 2.97,3 5.19,3s4.15,-1.21 5.19,-3H20v-2h-2.09c0.05,-0.33 0.09,-0.66 0.09,-1v-1h2v-2h-2v-1c0,-0.34 -0.04,-0.67 -0.09,-1H20V8zM16,15c0,0.22 -0.03,0.47 -0.07,0.7l-0.1,0.65 -0.37,0.65c-0.72,1.24 -2.04,2 -3.46,2s-2.74,-0.77 -3.46,-2l-0.37,-0.64 -0.1,-0.65C8.03,15.48 8,15.23 8,15v-4c0,-0.21 0.03,-0.47 0.07,-0.7l0.1,-0.65 0.37,-0.65c0.3,-0.52 0.72,-0.97 1.21,-1.31l0.57,-0.39 0.74,-0.18C11.39,7.04 11.7,7 12,7s0.62,0.04 0.95,0.12l0.68,0.16 0.61,0.42c0.5,0.34 0.91,0.78 1.21,1.31l0.38,0.65 0.1,0.65C15.97,10.53 16,10.78 16,11v4zM10,14h4v2h-4zM10,10h4v2h-4z"/> diff --git a/app/src/main/res/drawable/ic_nav_home.xml b/app/src/main/res/drawable/ic_nav_home.xml new file mode 100644 index 00000000..f6217a52 --- /dev/null +++ b/app/src/main/res/drawable/ic_nav_home.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_nav_networks.xml b/app/src/main/res/drawable/ic_nav_networks.xml new file mode 100644 index 00000000..3b2c9f15 --- /dev/null +++ b/app/src/main/res/drawable/ic_nav_networks.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_nav_peers.xml b/app/src/main/res/drawable/ic_nav_peers.xml new file mode 100644 index 00000000..26faa73f --- /dev/null +++ b/app/src/main/res/drawable/ic_nav_peers.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_nav_settings.xml b/app/src/main/res/drawable/ic_nav_settings.xml new file mode 100644 index 00000000..8a97d126 --- /dev/null +++ b/app/src/main/res/drawable/ic_nav_settings.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_open_in_new.xml b/app/src/main/res/drawable/ic_open_in_new.xml new file mode 100644 index 00000000..b705126d --- /dev/null +++ b/app/src/main/res/drawable/ic_open_in_new.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_profile_avatar.xml b/app/src/main/res/drawable/ic_profile_avatar.xml new file mode 100644 index 00000000..5d2556c3 --- /dev/null +++ b/app/src/main/res/drawable/ic_profile_avatar.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/profile_chip_bg.xml b/app/src/main/res/drawable/profile_chip_bg.xml new file mode 100644 index 00000000..b9c31083 --- /dev/null +++ b/app/src/main/res/drawable/profile_chip_bg.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/settings_row_bg.xml b/app/src/main/res/drawable/settings_row_bg.xml new file mode 100644 index 00000000..f2c8465c --- /dev/null +++ b/app/src/main/res/drawable/settings_row_bg.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/sheet_row_bg.xml b/app/src/main/res/drawable/sheet_row_bg.xml new file mode 100644 index 00000000..85afe650 --- /dev/null +++ b/app/src/main/res/drawable/sheet_row_bg.xml @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/app/src/main/res/layout-w960dp/activity_main.xml b/app/src/main/res/layout-w960dp/activity_main.xml new file mode 100644 index 00000000..447a6190 --- /dev/null +++ b/app/src/main/res/layout-w960dp/activity_main.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 1e23e1dc..8c82b039 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,62 +1,47 @@ - + tools:context=".MainActivity"> - + android:layout_height="wrap_content" + android:theme="@style/Theme.NetBird.AppBarOverlay"> - - - - - + android:layout_height="?attr/actionBarSize" + android:background="@color/nb_bg" + app:popupTheme="@style/Theme.NetBird.PopupOverlay" /> - + - - + + + - - \ No newline at end of file + diff --git a/app/src/main/res/layout/app_bar_main.xml b/app/src/main/res/layout/app_bar_main.xml deleted file mode 100644 index 7fd9cfdb..00000000 --- a/app/src/main/res/layout/app_bar_main.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/content_main.xml b/app/src/main/res/layout/content_main.xml deleted file mode 100644 index 6e0ea390..00000000 --- a/app/src/main/res/layout/content_main.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_about.xml b/app/src/main/res/layout/fragment_about.xml index 508fad19..9fb6b4ac 100644 --- a/app/src/main/res/layout/fragment_about.xml +++ b/app/src/main/res/layout/fragment_about.xml @@ -73,7 +73,7 @@ android:id="@+id/text_rights" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginBottom="32dp" + android:layout_marginBottom="112dp" android:textAlignment="center" android:text="@string/about_rights" app:layout_constraintBottom_toBottomOf="parent" diff --git a/app/src/main/res/layout/fragment_advanced.xml b/app/src/main/res/layout/fragment_advanced.xml index ad46aea4..9675626a 100644 --- a/app/src/main/res/layout/fragment_advanced.xml +++ b/app/src/main/res/layout/fragment_advanced.xml @@ -5,47 +5,37 @@ android:id="@+id/scr_vw_advanced" android:layout_width="match_parent" android:layout_height="match_parent" + android:background="@color/nb_bg" + android:clipToPadding="false" android:fillViewport="true" + android:paddingBottom="@dimen/bottom_nav_inset" tools:context=".ui.advanced.AdvancedFragment"> + android:orientation="vertical" + android:paddingBottom="32dp"> - + + + - - - - + android:background="@color/nb_bg_peers" + android:orientation="vertical" + android:paddingStart="20dp" + android:paddingEnd="20dp" + android:paddingTop="12dp" + android:paddingBottom="12dp"> + android:textSize="14sp" />