1
0
mirror of https://github.com/bitwarden/mobile synced 2026-01-07 19:13:19 +00:00

vault list grouping page

This commit is contained in:
Kyle Spearrin
2017-11-24 23:15:25 -05:00
parent c9ceb09906
commit aaea0b2659
26 changed files with 711 additions and 158 deletions

View File

@@ -0,0 +1,14 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Bit.App.Models;
using System;
namespace Bit.App.Abstractions
{
public interface ICollectionService
{
Task<Collection> GetByIdAsync(string id);
Task<IEnumerable<Collection>> GetAllAsync();
Task<IEnumerable<Tuple<string, string>>> GetAllCipherAssociationsAsync();
}
}

View File

@@ -45,6 +45,7 @@
<Compile Include="Abstractions\Repositories\IDeviceApiRepository.cs" />
<Compile Include="Abstractions\Repositories\ISettingsRepository.cs" />
<Compile Include="Abstractions\Services\IAppSettingsService.cs" />
<Compile Include="Abstractions\Services\ICollectionService.cs" />
<Compile Include="Abstractions\Services\IMemoryService.cs" />
<Compile Include="Abstractions\Services\IPushNotificationListener.cs" />
<Compile Include="Abstractions\Services\IPushNotification.cs" />
@@ -75,6 +76,7 @@
<Compile Include="Controls\ExtendedContentPage.cs" />
<Compile Include="Controls\LabeledRightDetailCell.cs" />
<Compile Include="Controls\MemoryContentView.cs" />
<Compile Include="Controls\SectionHeaderViewCell.cs" />
<Compile Include="Controls\StepperCell.cs" />
<Compile Include="Controls\ExtendedTableView.cs" />
<Compile Include="Controls\ExtendedPicker.cs" />
@@ -90,6 +92,7 @@
<Compile Include="Controls\FormEntryCell.cs" />
<Compile Include="Controls\PinControl.cs" />
<Compile Include="Controls\VaultAttachmentsViewCell.cs" />
<Compile Include="Controls\VaultGroupingViewCell.cs" />
<Compile Include="Controls\VaultListViewCell.cs" />
<Compile Include="Enums\DeviceType.cs" />
<Compile Include="Enums\FieldType.cs" />
@@ -189,6 +192,7 @@
<Compile Include="Pages\Vault\VaultCustomFieldsPage.cs" />
<Compile Include="Pages\Vault\VaultAutofillListCiphersPage.cs" />
<Compile Include="Pages\Vault\VaultAttachmentsPage.cs" />
<Compile Include="Pages\Vault\VaultListGroupingsPage.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="Abstractions\Repositories\ICipherRepository.cs" />
<Compile Include="Repositories\AttachmentRepository.cs" />
@@ -350,6 +354,7 @@
<DependentUpon>AppResources.zh-Hant.resx</DependentUpon>
</Compile>
<Compile Include="Services\AppSettingsService.cs" />
<Compile Include="Services\CollectionService.cs" />
<Compile Include="Services\SettingsService.cs" />
<Compile Include="Services\TokenService.cs" />
<Compile Include="Services\AppIdService.cs" />

View File

@@ -0,0 +1,43 @@
using Xamarin.Forms;
namespace Bit.App.Controls
{
public class SectionHeaderViewCell : ExtendedViewCell
{
public SectionHeaderViewCell(string bindingName, string countBindingName = null, Thickness? padding = null)
{
var label = new Label
{
FontSize = Device.GetNamedSize(NamedSize.Small, typeof(Label)),
Style = (Style)Application.Current.Resources["text-muted"],
VerticalTextAlignment = TextAlignment.Center,
HorizontalOptions = LayoutOptions.StartAndExpand
};
label.SetBinding(Label.TextProperty, bindingName);
var stackLayout = new StackLayout
{
Padding = padding ?? new Thickness(16, 8, 0, 8),
Children = { label },
Orientation = StackOrientation.Horizontal
};
if(!string.IsNullOrWhiteSpace(countBindingName))
{
var countLabel = new Label
{
LineBreakMode = LineBreakMode.NoWrap,
FontSize = Device.GetNamedSize(NamedSize.Small, typeof(Label)),
Style = (Style)Application.Current.Resources["text-muted"],
HorizontalOptions = LayoutOptions.End
};
countLabel.SetBinding(Label.TextProperty, countBindingName);
stackLayout.Children.Add(countLabel);
}
View = stackLayout;
BackgroundColor = Color.FromHex("efeff4");
}
}
}

View File

@@ -0,0 +1,79 @@
using Bit.App.Models.Page;
using FFImageLoading.Forms;
using System;
using Xamarin.Forms;
namespace Bit.App.Controls
{
public class VaultGroupingViewCell : ExtendedViewCell
{
public static readonly BindableProperty GroupingParameterProeprty = BindableProperty.Create(nameof(GroupingParameter),
typeof(VaultListPageModel.Grouping), typeof(VaultGroupingViewCell), null);
public VaultGroupingViewCell()
{
Icon = new CachedImage
{
WidthRequest = 20,
HeightRequest = 20,
HorizontalOptions = LayoutOptions.Center,
VerticalOptions = LayoutOptions.Center,
Source = "folder.png",
Margin = new Thickness(0, 0, 10, 0)
};
Label = new Label
{
LineBreakMode = LineBreakMode.TailTruncation,
FontSize = Device.GetNamedSize(NamedSize.Medium, typeof(Label)),
HorizontalOptions = LayoutOptions.StartAndExpand
};
Label.SetBinding(Label.TextProperty, nameof(VaultListPageModel.Grouping.Name));
CountLabel = new Label
{
LineBreakMode = LineBreakMode.NoWrap,
FontSize = Device.GetNamedSize(NamedSize.Small, typeof(Label)),
Style = (Style)Application.Current.Resources["text-muted"],
HorizontalOptions = LayoutOptions.End
};
CountLabel.SetBinding(Label.TextProperty, nameof(VaultListPageModel.Grouping.CipherCount));
var stackLayout = new StackLayout
{
Spacing = 0,
Padding = new Thickness(16, 8),
Children = { Icon, Label, CountLabel },
Orientation = StackOrientation.Horizontal
};
if(Device.RuntimePlatform == Device.Android)
{
Label.TextColor = Color.Black;
}
View = stackLayout;
BackgroundColor = Color.White;
SetBinding(GroupingParameterProeprty, new Binding("."));
}
public VaultListPageModel.Grouping GroupingParameter
{
get => GetValue(GroupingParameterProeprty) as VaultListPageModel.Grouping;
set { SetValue(GroupingParameterProeprty, value); }
}
public CachedImage Icon { get; private set; }
public Label Label { get; private set; }
public Label CountLabel { get; private set; }
protected override void OnBindingContextChanged()
{
if(BindingContext is VaultListPageModel.Grouping grouping)
{
Icon.Source = grouping.Folder ? "folder.png" : "cube.png";
}
base.OnBindingContextChanged();
}
}
}

View File

@@ -1,5 +1,4 @@
using Bit.App.Models.Page;
using FFImageLoading.Forms;
using System;
using Xamarin.Forms;

View File

@@ -165,6 +165,52 @@ namespace Bit.App.Models.Page
public string Name { get; set; } = AppResources.FolderNone;
}
public class Section : List<Grouping>
{
public Section(List<Grouping> groupings, string name)
{
AddRange(groupings);
Name = name.ToUpperInvariant();
ItemCount = groupings.Count;
}
public string Name { get; set; }
public int ItemCount { get; set; }
}
public class Grouping
{
public Grouping(string name, int count)
{
Id = null;
Name = name;
Folder = true;
CipherCount = count;
}
public Grouping(Models.Folder folder, int count)
{
Id = folder.Id;
Name = folder.Name?.Decrypt();
Folder = true;
CipherCount = count;
}
public Grouping(Collection collection, int count)
{
Id = collection.Id;
Name = collection.Name?.Decrypt(collection.OrganizationId);
Collection = true;
CipherCount = count;
}
public string Id { get; set; }
public string Name { get; set; } = AppResources.FolderNone;
public int CipherCount { get; set; }
public bool Folder { get; set; }
public bool Collection { get; set; }
}
public class AutofillGrouping : List<AutofillCipher>
{
public AutofillGrouping(List<AutofillCipher> logins, string name)

View File

@@ -13,7 +13,7 @@ namespace Bit.App.Pages
var settingsNavigation = new ExtendedNavigationPage(new SettingsPage());
var favoritesNavigation = new ExtendedNavigationPage(new VaultListCiphersPage(true));
var vaultNavigation = new ExtendedNavigationPage(new VaultListCiphersPage(false));
var vaultNavigation = new ExtendedNavigationPage(new VaultListGroupingsPage());
var toolsNavigation = new ExtendedNavigationPage(new ToolsPage());
favoritesNavigation.Icon = "star.png";

View File

@@ -99,7 +99,8 @@ namespace Bit.App.Pages
IsGroupingEnabled = true,
ItemsSource = PresentationCiphersGroup,
HasUnevenRows = true,
GroupHeaderTemplate = new DataTemplate(() => new HeaderViewCell()),
GroupHeaderTemplate = new DataTemplate(() => new SectionHeaderViewCell(
nameof(VaultListPageModel.AutofillGrouping.Name))),
ItemTemplate = new DataTemplate(() => new VaultListViewCell(
(VaultListPageModel.Cipher l) => MoreClickedAsync(l)))
};
@@ -359,29 +360,5 @@ namespace Bit.App.Pages
TimeSpan.FromSeconds(10));
}
}
private class HeaderViewCell : ExtendedViewCell
{
public HeaderViewCell()
{
var label = new Label
{
FontSize = Device.GetNamedSize(NamedSize.Medium, typeof(Label)),
Style = (Style)Application.Current.Resources["text-muted"],
VerticalTextAlignment = TextAlignment.Center
};
label.SetBinding(Label.TextProperty, nameof(VaultListPageModel.AutofillGrouping.Name));
var grid = new ContentView
{
Padding = new Thickness(16, 8, 0, 8),
Content = label
};
View = grid;
BackgroundColor = Color.FromHex("efeff4");
}
}
}
}

View File

@@ -0,0 +1,296 @@
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 VaultListGroupingsPage : ExtendedContentPage
{
private readonly IFolderService _folderService;
private readonly ICollectionService _collectionService;
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 VaultListGroupingsPage()
: base(true)
{
_folderService = Resolver.Resolve<IFolderService>();
_collectionService = Resolver.Resolve<ICollectionService>();
_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.Section> PresentationSections { get; private set; }
= new ExtendedObservableCollection<VaultListPageModel.Section>();
public ListView ListView { get; set; }
public SearchBar Search { get; set; }
public StackLayout NoDataStackLayout { get; set; }
public StackLayout ResultsStackLayout { get; set; }
public ActivityIndicator LoadingIndicator { get; set; }
private AddCipherToolBarItem AddCipherItem { get; set; }
private void Init()
{
AddCipherItem = new AddCipherToolBarItem(this);
ToolbarItems.Add(AddCipherItem);
ListView = new ListView(ListViewCachingStrategy.RecycleElement)
{
IsGroupingEnabled = true,
ItemsSource = PresentationSections,
HasUnevenRows = true,
GroupHeaderTemplate = new DataTemplate(() => new SectionHeaderViewCell(
nameof(VaultListPageModel.Section.Name), nameof(VaultListPageModel.Section.ItemCount),
new Thickness(16, Helpers.OnPlatform(20, 12, 12), 16, 12))),
ItemTemplate = new DataTemplate(() => new VaultGroupingViewCell())
};
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;
}
Title = AppResources.MyVault;
ResultsStackLayout = new StackLayout
{
Children = { Search, ListView },
Spacing = 0
};
var noDataLabel = new Label
{
Text = AppResources.NoItems,
HorizontalTextAlignment = TextAlignment.Center,
FontSize = Device.GetNamedSize(NamedSize.Small, typeof(Label)),
Style = (Style)Application.Current.Resources["text-muted"]
};
NoDataStackLayout = new StackLayout
{
Children = { noDataLabel },
VerticalOptions = LayoutOptions.CenterAndExpand,
Padding = new Thickness(20, 0),
Spacing = 20
};
var addCipherButton = new ExtendedButton
{
Text = AppResources.AddAnItem,
Command = new Command(() => AddCipher()),
Style = (Style)Application.Current.Resources["btn-primaryAccent"]
};
NoDataStackLayout.Children.Add(addCipherButton);
LoadingIndicator = new ActivityIndicator
{
IsRunning = true,
VerticalOptions = LayoutOptions.CenterAndExpand,
HorizontalOptions = LayoutOptions.Center
};
Content = LoadingIndicator;
}
protected override void OnAppearing()
{
base.OnAppearing();
MessagingCenter.Subscribe<ISyncService, bool>(_syncService, "SyncCompleted", (sender, success) =>
{
if(success)
{
_filterResultsCancellationTokenSource = FetchAndLoadVault();
}
});
ListView.ItemSelected += GroupingSelected;
//Search.TextChanged += SearchBar_TextChanged;
//Search.SearchButtonPressed += SearchBar_SearchButtonPressed;
AddCipherItem?.InitEvents();
_filterResultsCancellationTokenSource = FetchAndLoadVault();
}
protected override void OnDisappearing()
{
base.OnDisappearing();
MessagingCenter.Unsubscribe<ISyncService, bool>(_syncService, "SyncCompleted");
ListView.ItemSelected -= GroupingSelected;
//Search.TextChanged -= SearchBar_TextChanged;
//Search.SearchButtonPressed -= SearchBar_SearchButtonPressed;
AddCipherItem?.Dispose();
}
private void AdjustContent()
{
if(PresentationSections.Count > 0 || !string.IsNullOrWhiteSpace(Search.Text))
{
Content = ResultsStackLayout;
}
else
{
Content = NoDataStackLayout;
}
}
private CancellationTokenSource FetchAndLoadVault()
{
var cts = new CancellationTokenSource();
_filterResultsCancellationTokenSource?.Cancel();
Task.Run(async () =>
{
var sections = new List<VaultListPageModel.Section>();
var ciphers = await _cipherService.GetAllAsync();
var collectionsDict = (await _collectionService.GetAllCipherAssociationsAsync())
.GroupBy(c => c.Item2).ToDictionary(g => g.Key, v => v.ToList());
var folderCounts = new Dictionary<string, int> { ["none"] = 0 };
foreach(var cipher in ciphers)
{
if(cipher.FolderId != null)
{
if(!folderCounts.ContainsKey(cipher.FolderId))
{
folderCounts.Add(cipher.FolderId, 0);
}
folderCounts[cipher.FolderId]++;
}
else
{
folderCounts["none"]++;
}
}
var folders = await _folderService.GetAllAsync();
var folderGroupings = folders?
.Select(f => new VaultListPageModel.Grouping(f, folderCounts.ContainsKey(f.Id) ? folderCounts[f.Id] : 0))
.OrderBy(g => g.Name).ToList();
folderGroupings.Add(new VaultListPageModel.Grouping(AppResources.FolderNone, folderCounts["none"]));
if(folderGroupings?.Any() ?? false)
{
sections.Add(new VaultListPageModel.Section(folderGroupings, AppResources.Folders));
}
var collections = await _collectionService.GetAllAsync();
var collectionGroupings = collections?
.Select(c => new VaultListPageModel.Grouping(c,
collectionsDict.ContainsKey(c.Id) ? collectionsDict[c.Id].Count() : 0))
.OrderBy(g => g.Name).ToList();
if(collectionGroupings?.Any() ?? false)
{
sections.Add(new VaultListPageModel.Section(collectionGroupings, AppResources.Collections));
}
Device.BeginInvokeOnMainThread(() =>
{
if(sections.Any())
{
PresentationSections.ResetWithRange(sections);
}
AdjustContent();
});
}, cts.Token);
return cts;
}
private void GroupingSelected(object sender, SelectedItemChangedEventArgs e)
{
var grouping = e.SelectedItem as VaultListPageModel.Grouping;
if(grouping == null)
{
return;
}
((ListView)sender).SelectedItem = null;
}
private async void AddCipher()
{
var type = await _userDialogs.ActionSheetAsync(AppResources.SelectTypeAdd, AppResources.Cancel, null, null,
AppResources.TypeLogin, AppResources.TypeCard, AppResources.TypeIdentity, AppResources.TypeSecureNote);
var selectedType = CipherType.SecureNote;
if(type == AppResources.Cancel)
{
return;
}
else if(type == AppResources.TypeLogin)
{
selectedType = CipherType.Login;
}
else if(type == AppResources.TypeCard)
{
selectedType = CipherType.Card;
}
else if(type == AppResources.TypeIdentity)
{
selectedType = CipherType.Identity;
}
var page = new VaultAddCipherPage(selectedType);
await Navigation.PushForDeviceAsync(page);
}
private class AddCipherToolBarItem : ExtendedToolbarItem
{
private readonly VaultListGroupingsPage _page;
public AddCipherToolBarItem(VaultListGroupingsPage page)
: base(() => page.AddCipher())
{
_page = page;
Text = AppResources.Add;
Icon = "plus.png";
}
}
}
}

View File

@@ -673,6 +673,15 @@ namespace Bit.App.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Collections.
/// </summary>
public static string Collections {
get {
return ResourceManager.GetString("Collections", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Coming Soon!.
/// </summary>

View File

@@ -1194,4 +1194,7 @@
<data name="GoToMyVault" xml:space="preserve">
<value>Go to my vault</value>
</data>
<data name="Collections" xml:space="preserve">
<value>Collections</value>
</data>
</root>

View File

@@ -0,0 +1,52 @@
using System;
using System.Linq;
using System.Collections.Generic;
using System.Threading.Tasks;
using Bit.App.Abstractions;
using Bit.App.Models;
namespace Bit.App.Services
{
public class CollectionService : ICollectionService
{
private readonly ICollectionRepository _collectionRepository;
private readonly ICipherCollectionRepository _cipherCollectionRepository;
private readonly IAuthService _authService;
public CollectionService(
ICollectionRepository collectionRepository,
ICipherCollectionRepository cipherCollectionRepository,
IAuthService authService)
{
_collectionRepository = collectionRepository;
_cipherCollectionRepository = cipherCollectionRepository;
_authService = authService;
}
public async Task<Collection> GetByIdAsync(string id)
{
var data = await _collectionRepository.GetByIdAsync(id);
if(data == null || data.UserId != _authService.UserId)
{
return null;
}
var collection = new Collection(data);
return collection;
}
public async Task<IEnumerable<Collection>> GetAllAsync()
{
var data = await _collectionRepository.GetAllByUserIdAsync(_authService.UserId);
var collections = data.Select(c => new Collection(c));
return collections;
}
public async Task<IEnumerable<Tuple<string, string>>> GetAllCipherAssociationsAsync()
{
var data = await _cipherCollectionRepository.GetAllByUserIdAsync(_authService.UserId);
var assocs = data.Select(cc => new Tuple<string, string>(cc.CipherId, cc.CollectionId));
return assocs;
}
}
}