search page with name groups

This commit is contained in:
Kyle Spearrin 2017-11-25 15:43:43 -05:00
parent d8bb12b5f1
commit 9499b7f562
5 changed files with 399 additions and 47 deletions

View file

@ -192,6 +192,7 @@
<Compile Include="Pages\Vault\VaultCustomFieldsPage.cs" /> <Compile Include="Pages\Vault\VaultCustomFieldsPage.cs" />
<Compile Include="Pages\Vault\VaultAutofillListCiphersPage.cs" /> <Compile Include="Pages\Vault\VaultAutofillListCiphersPage.cs" />
<Compile Include="Pages\Vault\VaultAttachmentsPage.cs" /> <Compile Include="Pages\Vault\VaultAttachmentsPage.cs" />
<Compile Include="Pages\Vault\VaultSearchCiphersPage.cs" />
<Compile Include="Pages\Vault\VaultListGroupingsPage.cs" /> <Compile Include="Pages\Vault\VaultListGroupingsPage.cs" />
<Compile Include="Properties\AssemblyInfo.cs" /> <Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="Abstractions\Repositories\ICipherRepository.cs" /> <Compile Include="Abstractions\Repositories\ICipherRepository.cs" />

View file

@ -18,7 +18,7 @@ namespace Bit.App.Controls
var stackLayout = new StackLayout var stackLayout = new StackLayout
{ {
Padding = padding ?? new Thickness(16, 8, 0, 8), Padding = padding ?? new Thickness(16, 8),
Children = { label }, Children = { label },
Orientation = StackOrientation.Horizontal Orientation = StackOrientation.Horizontal
}; };

View file

@ -21,6 +21,23 @@ namespace Bit.App.Models.Page
Name = cipher.Name?.Decrypt(cipher.OrganizationId); Name = cipher.Name?.Decrypt(cipher.OrganizationId);
Type = cipher.Type; Type = cipher.Type;
if(string.IsNullOrWhiteSpace(Name) || Name.Length == 0)
{
NameGroup = AppResources.Other;
}
else if(Char.IsLetter(Name[0]))
{
NameGroup = Name[0].ToString();
}
else if(Char.IsDigit(Name[0]))
{
NameGroup = "0 - 9";
}
else
{
NameGroup = AppResources.Other;
}
switch(cipher.Type) switch(cipher.Type)
{ {
case CipherType.Login: case CipherType.Login:
@ -120,6 +137,7 @@ namespace Bit.App.Models.Page
public bool Shared { get; set; } public bool Shared { get; set; }
public bool HasAttachments { get; set; } public bool HasAttachments { get; set; }
public string FolderId { get; set; } public string FolderId { get; set; }
public string NameGroup { get; set; }
public string Name { get; set; } public string Name { get; set; }
public string Subtitle { get; set; } public string Subtitle { get; set; }
public CipherType Type { get; set; } public CipherType Type { get; set; }
@ -165,6 +183,17 @@ namespace Bit.App.Models.Page
public string Name { get; set; } = AppResources.FolderNone; public string Name { get; set; } = AppResources.FolderNone;
} }
public class NameGroup : List<Cipher>
{
public NameGroup(string nameGroup, List<Cipher> ciphers)
{
Name = nameGroup.ToUpperInvariant();
AddRange(ciphers);
}
public string Name { get; set; }
}
public class Section : List<Grouping> public class Section : List<Grouping>
{ {
public Section(List<Grouping> groupings, string name) public Section(List<Grouping> groupings, string name)

View file

@ -55,15 +55,16 @@ namespace Bit.App.Pages
public ExtendedObservableCollection<VaultListPageModel.Section> PresentationSections { get; private set; } public ExtendedObservableCollection<VaultListPageModel.Section> PresentationSections { get; private set; }
= new ExtendedObservableCollection<VaultListPageModel.Section>(); = new ExtendedObservableCollection<VaultListPageModel.Section>();
public ListView ListView { get; set; } public ListView ListView { get; set; }
public SearchBar Search { get; set; }
public StackLayout NoDataStackLayout { get; set; } public StackLayout NoDataStackLayout { get; set; }
public StackLayout ResultsStackLayout { get; set; }
public ActivityIndicator LoadingIndicator { get; set; } public ActivityIndicator LoadingIndicator { get; set; }
private AddCipherToolBarItem AddCipherItem { get; set; } private AddCipherToolBarItem AddCipherItem { get; set; }
private SearchToolBarItem SearchItem { get; set; }
private void Init() private void Init()
{ {
SearchItem = new SearchToolBarItem(this);
AddCipherItem = new AddCipherToolBarItem(this); AddCipherItem = new AddCipherToolBarItem(this);
ToolbarItems.Add(SearchItem);
ToolbarItems.Add(AddCipherItem); ToolbarItems.Add(AddCipherItem);
ListView = new ListView(ListViewCachingStrategy.RecycleElement) ListView = new ListView(ListViewCachingStrategy.RecycleElement)
@ -82,26 +83,6 @@ namespace Bit.App.Pages
ListView.RowHeight = -1; ListView.RowHeight = -1;
} }
Search = new SearchBar
{
Placeholder = AppResources.SearchVault,
FontSize = Device.GetNamedSize(NamedSize.Small, typeof(Button)),
CancelButtonColor = Color.FromHex("3c8dbc")
};
// Bug with searchbar on android 7, ref https://bugzilla.xamarin.com/show_bug.cgi?id=43975
if(Device.RuntimePlatform == Device.Android && _deviceInfoService.Version >= 24)
{
Search.HeightRequest = 50;
}
Title = AppResources.MyVault;
ResultsStackLayout = new StackLayout
{
Children = { Search, ListView },
Spacing = 0
};
var noDataLabel = new Label var noDataLabel = new Label
{ {
Text = AppResources.NoItems, Text = AppResources.NoItems,
@ -135,6 +116,7 @@ namespace Bit.App.Pages
}; };
Content = LoadingIndicator; Content = LoadingIndicator;
Title = AppResources.MyVault;
} }
protected override void OnAppearing() protected override void OnAppearing()
@ -149,9 +131,8 @@ namespace Bit.App.Pages
}); });
ListView.ItemSelected += GroupingSelected; ListView.ItemSelected += GroupingSelected;
//Search.TextChanged += SearchBar_TextChanged;
//Search.SearchButtonPressed += SearchBar_SearchButtonPressed;
AddCipherItem?.InitEvents(); AddCipherItem?.InitEvents();
SearchItem?.InitEvents();
_filterResultsCancellationTokenSource = FetchAndLoadVault(); _filterResultsCancellationTokenSource = FetchAndLoadVault();
} }
@ -162,21 +143,8 @@ namespace Bit.App.Pages
MessagingCenter.Unsubscribe<ISyncService, bool>(_syncService, "SyncCompleted"); MessagingCenter.Unsubscribe<ISyncService, bool>(_syncService, "SyncCompleted");
ListView.ItemSelected -= GroupingSelected; ListView.ItemSelected -= GroupingSelected;
//Search.TextChanged -= SearchBar_TextChanged;
//Search.SearchButtonPressed -= SearchBar_SearchButtonPressed;
AddCipherItem?.Dispose(); AddCipherItem?.Dispose();
} SearchItem?.Dispose();
private void AdjustContent()
{
if(PresentationSections.Count > 0 || !string.IsNullOrWhiteSpace(Search.Text))
{
Content = ResultsStackLayout;
}
else
{
Content = NoDataStackLayout;
}
} }
private CancellationTokenSource FetchAndLoadVault() private CancellationTokenSource FetchAndLoadVault()
@ -213,10 +181,7 @@ namespace Bit.App.Pages
.Select(f => new VaultListPageModel.Grouping(f, folderCounts.ContainsKey(f.Id) ? folderCounts[f.Id] : 0)) .Select(f => new VaultListPageModel.Grouping(f, folderCounts.ContainsKey(f.Id) ? folderCounts[f.Id] : 0))
.OrderBy(g => g.Name).ToList(); .OrderBy(g => g.Name).ToList();
folderGroupings.Add(new VaultListPageModel.Grouping(AppResources.FolderNone, folderCounts["none"])); folderGroupings.Add(new VaultListPageModel.Grouping(AppResources.FolderNone, folderCounts["none"]));
if(folderGroupings?.Any() ?? false)
{
sections.Add(new VaultListPageModel.Section(folderGroupings, AppResources.Folders)); sections.Add(new VaultListPageModel.Section(folderGroupings, AppResources.Folders));
}
var collections = await _collectionService.GetAllAsync(); var collections = await _collectionService.GetAllAsync();
var collectionGroupings = collections? var collectionGroupings = collections?
@ -235,7 +200,14 @@ namespace Bit.App.Pages
PresentationSections.ResetWithRange(sections); PresentationSections.ResetWithRange(sections);
} }
AdjustContent(); if(PresentationSections.Count > 0)
{
Content = ListView;
}
else
{
Content = NoDataStackLayout;
}
}); });
}, cts.Token); }, cts.Token);
@ -280,17 +252,30 @@ namespace Bit.App.Pages
await Navigation.PushForDeviceAsync(page); await Navigation.PushForDeviceAsync(page);
} }
private async void Search()
{
var page = new ExtendedNavigationPage(new VaultSearchCiphersPage());
await Navigation.PushModalAsync(page);
}
private class AddCipherToolBarItem : ExtendedToolbarItem private class AddCipherToolBarItem : ExtendedToolbarItem
{ {
private readonly VaultListGroupingsPage _page;
public AddCipherToolBarItem(VaultListGroupingsPage page) public AddCipherToolBarItem(VaultListGroupingsPage page)
: base(() => page.AddCipher()) : base(() => page.AddCipher())
{ {
_page = page;
Text = AppResources.Add; Text = AppResources.Add;
Icon = "plus.png"; Icon = "plus.png";
} }
} }
private class SearchToolBarItem : ExtendedToolbarItem
{
public SearchToolBarItem(VaultListGroupingsPage page)
: base(() => page.Search())
{
Text = AppResources.Search;
Icon = "search.png";
}
}
} }
} }

View file

@ -0,0 +1,337 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Acr.UserDialogs;
using Bit.App.Abstractions;
using Bit.App.Controls;
using Bit.App.Models.Page;
using Bit.App.Resources;
using Xamarin.Forms;
using XLabs.Ioc;
using Bit.App.Utilities;
using Plugin.Settings.Abstractions;
using Plugin.Connectivity.Abstractions;
using System.Collections.Generic;
using System.Threading;
using Bit.App.Enums;
namespace Bit.App.Pages
{
public class VaultSearchCiphersPage : ExtendedContentPage
{
private readonly IFolderService _folderService;
private readonly ICipherService _cipherService;
private readonly IUserDialogs _userDialogs;
private readonly IConnectivity _connectivity;
private readonly IDeviceActionService _deviceActionService;
private readonly ISyncService _syncService;
private readonly IPushNotificationService _pushNotification;
private readonly IDeviceInfoService _deviceInfoService;
private readonly ISettings _settings;
private readonly IAppSettingsService _appSettingsService;
private readonly IGoogleAnalyticsService _googleAnalyticsService;
private CancellationTokenSource _filterResultsCancellationTokenSource;
public VaultSearchCiphersPage()
: base(true)
{
_folderService = Resolver.Resolve<IFolderService>();
_cipherService = Resolver.Resolve<ICipherService>();
_connectivity = Resolver.Resolve<IConnectivity>();
_userDialogs = Resolver.Resolve<IUserDialogs>();
_deviceActionService = Resolver.Resolve<IDeviceActionService>();
_syncService = Resolver.Resolve<ISyncService>();
_pushNotification = Resolver.Resolve<IPushNotificationService>();
_deviceInfoService = Resolver.Resolve<IDeviceInfoService>();
_settings = Resolver.Resolve<ISettings>();
_appSettingsService = Resolver.Resolve<IAppSettingsService>();
_googleAnalyticsService = Resolver.Resolve<IGoogleAnalyticsService>();
Init();
}
public ExtendedObservableCollection<VaultListPageModel.NameGroup> PresentationLetters { get; private set; }
= new ExtendedObservableCollection<VaultListPageModel.NameGroup>();
public VaultListPageModel.Cipher[] Ciphers { get; set; } = new VaultListPageModel.Cipher[] { };
public ListView ListView { get; set; }
public SearchBar Search { get; set; }
public StackLayout ResultsStackLayout { get; set; }
private void Init()
{
ListView = new ListView(ListViewCachingStrategy.RecycleElement)
{
IsGroupingEnabled = true,
ItemsSource = PresentationLetters,
HasUnevenRows = true,
GroupHeaderTemplate = new DataTemplate(() => new SectionHeaderViewCell(
nameof(VaultListPageModel.NameGroup.Name), nameof(VaultListPageModel.NameGroup.Count))),
ItemTemplate = new DataTemplate(() => new VaultListViewCell(
(VaultListPageModel.Cipher c) => MoreClickedAsync(c)))
};
if(Device.RuntimePlatform == Device.iOS)
{
ListView.RowHeight = -1;
}
Search = new SearchBar
{
Placeholder = AppResources.SearchVault,
FontSize = Device.GetNamedSize(NamedSize.Small, typeof(Button)),
CancelButtonColor = Color.FromHex("3c8dbc")
};
// Bug with searchbar on android 7, ref https://bugzilla.xamarin.com/show_bug.cgi?id=43975
if(Device.RuntimePlatform == Device.Android && _deviceInfoService.Version >= 24)
{
Search.HeightRequest = 50;
}
ResultsStackLayout = new StackLayout
{
Children = { Search, ListView },
Spacing = 0
};
Title = AppResources.SearchVault;
Content = new ActivityIndicator
{
IsRunning = true,
VerticalOptions = LayoutOptions.CenterAndExpand,
HorizontalOptions = LayoutOptions.Center
};
}
private void SearchBar_SearchButtonPressed(object sender, EventArgs e)
{
_filterResultsCancellationTokenSource = FilterResultsBackground(((SearchBar)sender).Text,
_filterResultsCancellationTokenSource);
}
private void SearchBar_TextChanged(object sender, TextChangedEventArgs e)
{
var oldLength = e.OldTextValue?.Length ?? 0;
var newLength = e.NewTextValue?.Length ?? 0;
if(oldLength < 2 && newLength < 2 && oldLength < newLength)
{
return;
}
_filterResultsCancellationTokenSource = FilterResultsBackground(e.NewTextValue,
_filterResultsCancellationTokenSource);
}
private CancellationTokenSource FilterResultsBackground(string searchFilter,
CancellationTokenSource previousCts)
{
var cts = new CancellationTokenSource();
Task.Run(async () =>
{
if(!string.IsNullOrWhiteSpace(searchFilter))
{
await Task.Delay(300);
if(searchFilter != Search.Text)
{
return;
}
else
{
previousCts?.Cancel();
}
}
try
{
FilterResults(searchFilter, cts.Token);
}
catch(OperationCanceledException) { }
}, cts.Token);
return cts;
}
private void FilterResults(string searchFilter, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
if(string.IsNullOrWhiteSpace(searchFilter))
{
LoadLetters(Ciphers, ct);
}
else
{
searchFilter = searchFilter.ToLower();
var filteredCiphers = Ciphers
.Where(s => s.Name.ToLower().Contains(searchFilter) ||
(s.Subtitle?.ToLower().Contains(searchFilter) ?? false))
.TakeWhile(s => !ct.IsCancellationRequested)
.ToArray();
ct.ThrowIfCancellationRequested();
LoadLetters(filteredCiphers, ct);
}
}
protected override void OnAppearing()
{
base.OnAppearing();
MessagingCenter.Subscribe<ISyncService, bool>(_syncService, "SyncCompleted", (sender, success) =>
{
if(success)
{
_filterResultsCancellationTokenSource = FetchAndLoadVault();
}
});
ListView.ItemSelected += CipherSelected;
Search.TextChanged += SearchBar_TextChanged;
Search.SearchButtonPressed += SearchBar_SearchButtonPressed;
_filterResultsCancellationTokenSource = FetchAndLoadVault();
}
protected override void OnDisappearing()
{
base.OnDisappearing();
MessagingCenter.Unsubscribe<ISyncService, bool>(_syncService, "SyncCompleted");
ListView.ItemSelected -= CipherSelected;
Search.TextChanged -= SearchBar_TextChanged;
Search.SearchButtonPressed -= SearchBar_SearchButtonPressed;
}
private CancellationTokenSource FetchAndLoadVault()
{
var cts = new CancellationTokenSource();
if(PresentationLetters.Count > 0 && _syncService.SyncInProgress)
{
return cts;
}
_filterResultsCancellationTokenSource?.Cancel();
Task.Run(async () =>
{
var ciphers = await _cipherService.GetAllAsync();
Ciphers = ciphers
.Select(s => new VaultListPageModel.Cipher(s, _appSettingsService))
.OrderBy(s =>
{
// Sort numbers and letters before special characters
return !string.IsNullOrWhiteSpace(s.Name) && s.Name.Length > 0 &&
Char.IsLetterOrDigit(s.Name[0]) ? 0 : 1;
})
.ThenBy(s => s.Name)
.ThenBy(s => s.Subtitle)
.ToArray();
try
{
FilterResults(Search.Text, cts.Token);
}
catch(OperationCanceledException) { }
}, cts.Token);
return cts;
}
private void LoadLetters(VaultListPageModel.Cipher[] ciphers, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
var letterGroups = ciphers.GroupBy(c => c.NameGroup)
.Select(g => new VaultListPageModel.NameGroup(g.Key, g.ToList()));
ct.ThrowIfCancellationRequested();
Device.BeginInvokeOnMainThread(() =>
{
PresentationLetters.ResetWithRange(letterGroups);
Content = ResultsStackLayout;
});
}
private async void CipherSelected(object sender, SelectedItemChangedEventArgs e)
{
var cipher = e.SelectedItem as VaultListPageModel.Cipher;
if(cipher == null)
{
return;
}
var page = new VaultViewCipherPage(cipher.Type, cipher.Id);
await Navigation.PushForDeviceAsync(page);
((ListView)sender).SelectedItem = null;
}
private async void MoreClickedAsync(VaultListPageModel.Cipher cipher)
{
var buttons = new List<string> { AppResources.View, AppResources.Edit };
if(cipher.Type == CipherType.Login)
{
if(!string.IsNullOrWhiteSpace(cipher.LoginPassword.Value))
{
buttons.Add(AppResources.CopyPassword);
}
if(!string.IsNullOrWhiteSpace(cipher.LoginUsername))
{
buttons.Add(AppResources.CopyUsername);
}
if(!string.IsNullOrWhiteSpace(cipher.LoginUri) && (cipher.LoginUri.StartsWith("http://")
|| cipher.LoginUri.StartsWith("https://")))
{
buttons.Add(AppResources.GoToWebsite);
}
}
else if(cipher.Type == CipherType.Card)
{
if(!string.IsNullOrWhiteSpace(cipher.CardNumber))
{
buttons.Add(AppResources.CopyNumber);
}
if(!string.IsNullOrWhiteSpace(cipher.CardCode.Value))
{
buttons.Add(AppResources.CopySecurityCode);
}
}
var selection = await DisplayActionSheet(cipher.Name, AppResources.Cancel, null, buttons.ToArray());
if(selection == AppResources.View)
{
var page = new VaultViewCipherPage(cipher.Type, cipher.Id);
await Navigation.PushForDeviceAsync(page);
}
else if(selection == AppResources.Edit)
{
var page = new VaultEditCipherPage(cipher.Id);
await Navigation.PushForDeviceAsync(page);
}
else if(selection == AppResources.CopyPassword)
{
Copy(cipher.LoginPassword.Value, AppResources.Password);
}
else if(selection == AppResources.CopyUsername)
{
Copy(cipher.LoginUsername, AppResources.Username);
}
else if(selection == AppResources.GoToWebsite)
{
Device.OpenUri(new Uri(cipher.LoginUri));
}
else if(selection == AppResources.CopyNumber)
{
Copy(cipher.CardNumber, AppResources.Number);
}
else if(selection == AppResources.CopySecurityCode)
{
Copy(cipher.CardCode.Value, AppResources.SecurityCode);
}
}
private void Copy(string copyText, string alertLabel)
{
_deviceActionService.CopyToClipboard(copyText);
_userDialogs.Toast(string.Format(AppResources.ValueHasBeenCopied, alertLabel));
}
}
}