From 5db94edc7e36cf7999f0d9b5ffa959377709ef5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arkadiusz=20Kr=C3=B3l?= Date: Tue, 10 Dec 2024 18:47:57 +0100 Subject: [PATCH] Delete from queue (#639) * iOS - swipe implementation * Android - swipe implementation * Handling queue modified * Delete from queue toast * Cleanup * iOS build fix * Delete from queue when Shuffle enabled fix --- BMM.Core/Constants/ViewConstants.cs | 2 + .../TrackOptions/PrepareTrackOptionsAction.cs | 6 + BMM.Core/Helpers/ListHelper.cs | 24 +- .../ViewModelHandlingMediaPlayerDecorator.cs | 23 +- .../MediaPlayer/QueueChangedMessage.cs | 10 + BMM.Core/Models/Enums/SwipePlacement.cs | 7 + BMM.Core/Models/POs/Tracks/TrackPO.cs | 7 + .../Abstractions/IMediaPlayer.cs | 2 + .../Abstractions/IMediaQueue.cs | 7 +- BMM.Core/NewMediaPlayer/MediaQueue.cs | 22 +- BMM.Core/NewMediaPlayer/ShuffleableQueue.cs | 14 + .../Translation/en/Translations.designer.cs | 4 + BMM.Core/Translation/en/main.json | 6 +- BMM.Core/ViewModels/OptionsListViewModel.cs | 3 + BMM.Core/ViewModels/QueueViewModel.cs | 31 +- .../Adapters/QueueRecyclerAdapter.cs | 25 ++ .../Adapters/Swipes/BaseSwipeMenuAdapter.cs | 88 +++++ .../Adapters/Swipes/ISwipeMenuAdapter.cs | 9 + .../Application/Constants/States.cs | 18 + .../CustomViews/Swipes/SwipeFrameLayout.cs | 61 +++ .../CustomViews/Swipes/SwipeMenuControl.cs | 198 ++++++++++ .../CustomViews/Swipes/SwipeMenuView.cs | 154 ++++++++ .../Application/Fragments/QueueFragment.cs | 17 +- .../Controller/AndroidMediaPlayer.cs | 51 +++ .../NewMediaPlayer/CustomShuffleOrder.cs | 13 +- .../Listeners/PlayerListener.cs | 8 +- .../Notification/MusicServiceMediaCallback.cs | 6 + .../Playback/SingleMediaSourceFactory.cs | 2 + .../ViewHolders/Base/SwipeMenuViewHolder.cs | 318 ++++++++++++++++ .../ViewHolders/QueueItemViewHolder.cs | 60 +++ .../Resources/layout/fragment_queue.axml | 2 +- .../Resources/layout/view_swipe_menu.axml | 25 ++ .../layout/view_swipe_menu_simple.xml | 14 + .../CustomViews/Swipes/Base/SwipeMenuBase.cs | 133 +++++++ .../CustomViews/Swipes/SwipeMenuSimpleItem.cs | 21 ++ .../Swipes/SwipeMenuSimpleItem.designer.cs | 65 ++++ .../Swipes/SwipeMenuSimpleItem.xib | 56 +++ BMM.UI.iOS/Application/Enums/PanDirection.cs | 8 + .../PanDirectionGestureRecognizer.cs | 32 ++ .../NewMediaPlayer/IosMediaPlayer.cs | 16 +- .../TableViewCell/Base/SwipeableViewCell.cs | 317 ++++++++++++++++ .../TableViewCell/Base/UISwipeMenu.cs | 355 ++++++++++++++++++ .../TableViewCell/TrackTableViewCell.cs | 38 +- .../TableViewCell/TrackTableViewCell.xib | 174 +++++---- .../Base/ISwipeableTableViewSource.cs | 8 + BMM.UI.iOS/Constants/ConstraintsConstants.cs | 9 + 46 files changed, 2363 insertions(+), 106 deletions(-) create mode 100644 BMM.Core/Messages/MediaPlayer/QueueChangedMessage.cs create mode 100644 BMM.Core/Models/Enums/SwipePlacement.cs create mode 100644 BMM.UI.Android/Application/Adapters/QueueRecyclerAdapter.cs create mode 100644 BMM.UI.Android/Application/Adapters/Swipes/BaseSwipeMenuAdapter.cs create mode 100644 BMM.UI.Android/Application/Adapters/Swipes/ISwipeMenuAdapter.cs create mode 100644 BMM.UI.Android/Application/Constants/States.cs create mode 100644 BMM.UI.Android/Application/CustomViews/Swipes/SwipeFrameLayout.cs create mode 100644 BMM.UI.Android/Application/CustomViews/Swipes/SwipeMenuControl.cs create mode 100644 BMM.UI.Android/Application/CustomViews/Swipes/SwipeMenuView.cs create mode 100644 BMM.UI.Android/Application/ViewHolders/Base/SwipeMenuViewHolder.cs create mode 100644 BMM.UI.Android/Application/ViewHolders/QueueItemViewHolder.cs create mode 100644 BMM.UI.Android/Resources/layout/view_swipe_menu.axml create mode 100644 BMM.UI.Android/Resources/layout/view_swipe_menu_simple.xml create mode 100644 BMM.UI.iOS/Application/CustomViews/Swipes/Base/SwipeMenuBase.cs create mode 100644 BMM.UI.iOS/Application/CustomViews/Swipes/SwipeMenuSimpleItem.cs create mode 100644 BMM.UI.iOS/Application/CustomViews/Swipes/SwipeMenuSimpleItem.designer.cs create mode 100644 BMM.UI.iOS/Application/CustomViews/Swipes/SwipeMenuSimpleItem.xib create mode 100644 BMM.UI.iOS/Application/Enums/PanDirection.cs create mode 100644 BMM.UI.iOS/Application/GestureRecognizers/PanDirectionGestureRecognizer.cs create mode 100644 BMM.UI.iOS/Application/TableViewCell/Base/SwipeableViewCell.cs create mode 100644 BMM.UI.iOS/Application/TableViewCell/Base/UISwipeMenu.cs create mode 100644 BMM.UI.iOS/Application/TableViewSource/Base/ISwipeableTableViewSource.cs create mode 100644 BMM.UI.iOS/Constants/ConstraintsConstants.cs diff --git a/BMM.Core/Constants/ViewConstants.cs b/BMM.Core/Constants/ViewConstants.cs index 056e54d72..b2083468b 100644 --- a/BMM.Core/Constants/ViewConstants.cs +++ b/BMM.Core/Constants/ViewConstants.cs @@ -5,6 +5,8 @@ public class ViewConstants public const float LongAnimationDuration = 0.6f; public const float DefaultAnimationDuration = 0.3f; public const float QuickAnimationDuration = DefaultAnimationDuration / 2; + public const float SwiftAnimationDuration = 0.1f; + public const int SwiftAnimationDurationInMilliseconds = (int)(SwiftAnimationDuration * 1000); public const int DefaultAnimationDurationInMilliseconds = (int)(DefaultAnimationDuration * 1000); public const int LongAnimationDurationInMilliseconds = (int)(LongAnimationDuration * 1000); public const int QuickAnimationDurationInMilliseconds = (int)(QuickAnimationDuration * 1000); diff --git a/BMM.Core/GuardedActions/TrackOptions/PrepareTrackOptionsAction.cs b/BMM.Core/GuardedActions/TrackOptions/PrepareTrackOptionsAction.cs index 5aa0b5443..3ee167d27 100644 --- a/BMM.Core/GuardedActions/TrackOptions/PrepareTrackOptionsAction.cs +++ b/BMM.Core/GuardedActions/TrackOptions/PrepareTrackOptionsAction.cs @@ -340,6 +340,12 @@ await _mvxNavigationService.Navigate(new Album { await _showTrackInfoAction.ExecuteGuarded(track); }))); + + options.AddIf(() => sourceVM is QueueViewModel, + new StandardIconOptionPO( + _bmmLanguageBinder[Translations.QueueViewModel_RemoveFromQueueOption], + ImageResourceNames.IconRemove, + new MvxAsyncCommand(() => _mediaPlayer.DeleteFromQueue(track)))); return options; } diff --git a/BMM.Core/Helpers/ListHelper.cs b/BMM.Core/Helpers/ListHelper.cs index bbcfa5720..1fb52f3ab 100644 --- a/BMM.Core/Helpers/ListHelper.cs +++ b/BMM.Core/Helpers/ListHelper.cs @@ -11,12 +11,32 @@ public static int FindIndex(this IList source, Predicate match, int sta for (int i = startIndex; i < source.Count; i++) { if (match(source[i])) - { return i; - } } return NumericConstants.Undefined; } + + public static T FindNextAfter(this IList source, T item) + { + int nextIndex = source + .IndexOf(item) + 1; + + if (nextIndex >= source.Count) + return default; + + return source[nextIndex]; + } + + public static T FindPreviousBefore(this IList source, T item) + { + int previousIndex = source + .IndexOf(item) - 1; + + if (previousIndex < NumericConstants.Zero) + return default; + + return source[previousIndex]; + } } } diff --git a/BMM.Core/Implementations/Player/ViewModelHandlingMediaPlayerDecorator.cs b/BMM.Core/Implementations/Player/ViewModelHandlingMediaPlayerDecorator.cs index a7926e22b..a38938c21 100644 --- a/BMM.Core/Implementations/Player/ViewModelHandlingMediaPlayerDecorator.cs +++ b/BMM.Core/Implementations/Player/ViewModelHandlingMediaPlayerDecorator.cs @@ -7,10 +7,13 @@ using BMM.Core.Extensions; using BMM.Core.Implementations.Device; using BMM.Core.Implementations.LiveRadio; +using BMM.Core.Implementations.Localization.Interfaces; +using BMM.Core.Implementations.UI; using BMM.Core.Messages.MediaPlayer; using BMM.Core.NewMediaPlayer; using BMM.Core.NewMediaPlayer.Abstractions; using BMM.Core.NewMediaPlayer.Constants; +using BMM.Core.Translation; using BMM.Core.ViewModels; using MvvmCross.Navigation; using MvvmCross.Plugin.Messenger; @@ -31,6 +34,8 @@ public class ViewModelHandlingMediaPlayerDecorator : IMediaPlayer private readonly ILiveTime _liveTime; private readonly IMvxMessenger _mvxMessenger; + private readonly IToastDisplayer _toastDisplayer; + private readonly IBMMLanguageBinder _bmmLanguageBinder; private bool _isViewmodelShown; @@ -42,7 +47,9 @@ public ViewModelHandlingMediaPlayerDecorator( IMediaPlayerInitializer mediaPlayerInitializer, IPlayerErrorHandler playerErrorHandler, ILiveTime liveTime, - IMvxMessenger mvxMessenger) + IMvxMessenger mvxMessenger, + IToastDisplayer toastDisplayer, + IBMMLanguageBinder bmmLanguageBinder) { _deviceInfo = deviceInfo; _navigationService = navigationService; @@ -52,6 +59,8 @@ public ViewModelHandlingMediaPlayerDecorator( _playerErrorHandler = playerErrorHandler; _liveTime = liveTime; _mvxMessenger = mvxMessenger; + _toastDisplayer = toastDisplayer; + _bmmLanguageBinder = bmmLanguageBinder; _mediaPlayer.ContinuingPreviousSession = () => { ShowViewmodelIfNecessary(); }; } @@ -195,6 +204,18 @@ public void Stop() public decimal CurrentPlaybackSpeed => _mediaPlayer.CurrentPlaybackSpeed; + public async Task DeleteFromQueue(IMediaTrack track) + { + if (track.Id == CurrentTrack?.Id) + { + await _toastDisplayer.WarnAsync(_bmmLanguageBinder[Translations.QueueViewModel_CannotRemoveFromQueue]); + return; + } + + await _mediaPlayer.DeleteFromQueue(track); + await _toastDisplayer.Success(_bmmLanguageBinder[Translations.QueueViewModel_TrackRemovedFromQueue]); + } + public Task AddToEndOfQueue(IMediaTrack track, string playbackOrigin, bool ignoreIfAlreadyAdded = false) { if (ignoreIfAlreadyAdded && _queue.Tracks.Any(t => t.Equals(track))) diff --git a/BMM.Core/Messages/MediaPlayer/QueueChangedMessage.cs b/BMM.Core/Messages/MediaPlayer/QueueChangedMessage.cs new file mode 100644 index 000000000..7df864f20 --- /dev/null +++ b/BMM.Core/Messages/MediaPlayer/QueueChangedMessage.cs @@ -0,0 +1,10 @@ +using MvvmCross.Plugin.Messenger; + +namespace BMM.Core.Messages.MediaPlayer; + +public class QueueChangedMessage : MvxMessage +{ + public QueueChangedMessage(object sender) : base(sender) + { + } +} \ No newline at end of file diff --git a/BMM.Core/Models/Enums/SwipePlacement.cs b/BMM.Core/Models/Enums/SwipePlacement.cs new file mode 100644 index 000000000..04d79c1f0 --- /dev/null +++ b/BMM.Core/Models/Enums/SwipePlacement.cs @@ -0,0 +1,7 @@ +namespace BMM.Core.Models.Enums; + +public enum SwipePlacement +{ + Left, + Right +} \ No newline at end of file diff --git a/BMM.Core/Models/POs/Tracks/TrackPO.cs b/BMM.Core/Models/POs/Tracks/TrackPO.cs index 29197f8db..c051d0a86 100644 --- a/BMM.Core/Models/POs/Tracks/TrackPO.cs +++ b/BMM.Core/Models/POs/Tracks/TrackPO.cs @@ -6,6 +6,7 @@ using BMM.Core.Constants; using BMM.Core.Extensions; using BMM.Core.GuardedActions.Tracks.Interfaces; +using BMM.Core.Helpers; using BMM.Core.Implementations.Downloading.DownloadQueue; using BMM.Core.Implementations.FileStorage; using BMM.Core.Implementations.FirebaseRemoteConfig; @@ -65,6 +66,11 @@ public TrackPO( { await optionsClickedCommand.ExecuteAsync(Track); }); + + DeleteFromQueueCommand = new ExceptionHandlingCommand(async () => + { + await mediaPlayer.DeleteFromQueue(track); + }); RefreshState().FireAndForget(); SetTrackInformation(); @@ -72,6 +78,7 @@ public TrackPO( public IMvxAsyncCommand ShowTrackInfoCommand { get; } public IMvxAsyncCommand OptionButtonClickedCommand { get; } + public IMvxAsyncCommand DeleteFromQueueCommand { get; } private void SetTrackInformation() { diff --git a/BMM.Core/NewMediaPlayer/Abstractions/IMediaPlayer.cs b/BMM.Core/NewMediaPlayer/Abstractions/IMediaPlayer.cs index 97f7c6d8d..0b9c166bd 100644 --- a/BMM.Core/NewMediaPlayer/Abstractions/IMediaPlayer.cs +++ b/BMM.Core/NewMediaPlayer/Abstractions/IMediaPlayer.cs @@ -76,6 +76,8 @@ public interface ICommonMediaPlayer decimal CurrentPlaybackSpeed { get; } + Task DeleteFromQueue(IMediaTrack track); + Task AddToEndOfQueue(IMediaTrack track, string playbackOrigin, bool ignoreIfAlreadyAdded = false); Task QueueToPlayNext(IMediaTrack track, string playbackOrigin); diff --git a/BMM.Core/NewMediaPlayer/Abstractions/IMediaQueue.cs b/BMM.Core/NewMediaPlayer/Abstractions/IMediaQueue.cs index d1a84c9cc..0f1ef230c 100644 --- a/BMM.Core/NewMediaPlayer/Abstractions/IMediaQueue.cs +++ b/BMM.Core/NewMediaPlayer/Abstractions/IMediaQueue.cs @@ -1,6 +1,4 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using BMM.Api.Abstraction; +using BMM.Api.Abstraction; namespace BMM.Core.NewMediaPlayer.Abstractions { @@ -13,8 +11,11 @@ public interface IMediaQueue Task PlayNext(IMediaTrack track, IMediaTrack currentPlayedTrack); Task Append(IMediaTrack track); + + void Delete(IMediaTrack track); IList Tracks { get; } + bool HasPendingChanges { get; set; } bool IsSameQueue(IList newMediaTracks); diff --git a/BMM.Core/NewMediaPlayer/MediaQueue.cs b/BMM.Core/NewMediaPlayer/MediaQueue.cs index 370db35ff..f84791aa2 100644 --- a/BMM.Core/NewMediaPlayer/MediaQueue.cs +++ b/BMM.Core/NewMediaPlayer/MediaQueue.cs @@ -8,12 +8,14 @@ using BMM.Core.Implementations.Localization.Interfaces; using BMM.Core.Implementations.Media; using BMM.Core.Implementations.UI; +using BMM.Core.Messages.MediaPlayer; using BMM.Core.NewMediaPlayer.Abstractions; using BMM.Core.Translation; using BMM.Core.ViewModels.MyContent; using MvvmCross; using MvvmCross.Base; using MvvmCross.Localization; +using MvvmCross.Plugin.Messenger; namespace BMM.Core.NewMediaPlayer { @@ -26,19 +28,23 @@ public class MediaQueue : IMediaQueue private readonly MediaFileUrlSetter _mediaFileUrlSetter; private readonly IToastDisplayer _toastDisplayer; private readonly IBMMLanguageBinder _bmmLanguageBinder; - - public IList Tracks { get; private set; } + private readonly IMvxMessenger _mvxMessenger; public MediaQueue( MediaFileUrlSetter mediaFileUrlSetter, IToastDisplayer toastDisplayer, - IBMMLanguageBinder bmmLanguageBinder) + IBMMLanguageBinder bmmLanguageBinder, + IMvxMessenger mvxMessenger) { _mediaFileUrlSetter = mediaFileUrlSetter; _toastDisplayer = toastDisplayer; _bmmLanguageBinder = bmmLanguageBinder; + _mvxMessenger = mvxMessenger; Tracks = new List(); } + + public IList Tracks { get; private set; } + public bool HasPendingChanges { get; set; } public void Replace(IMediaTrack track) { @@ -119,6 +125,16 @@ public async Task PlayNext(IMediaTrack track, IMediaTrack currentPlayedTra return true; } + + public void Delete(IMediaTrack track) + { + lock (_lock) + { + Tracks.Remove(track); + _mvxMessenger.Publish(new QueueChangedMessage(this)); + HasPendingChanges = true; + } + } // todo this should be handled without checking the UI! private async Task FileNotDownloadedButInOfflineViewModel(IMediaTrack track) diff --git a/BMM.Core/NewMediaPlayer/ShuffleableQueue.cs b/BMM.Core/NewMediaPlayer/ShuffleableQueue.cs index 0323a23bc..d6ea412c9 100644 --- a/BMM.Core/NewMediaPlayer/ShuffleableQueue.cs +++ b/BMM.Core/NewMediaPlayer/ShuffleableQueue.cs @@ -48,6 +48,14 @@ public Task Append(IMediaTrack track) return _queue.Append(track); } + public void Delete(IMediaTrack track) + { + if (IsShuffleEnabled) + _shuffledTracks.Remove(track); + + _queue.Delete(track); + } + public Task PlayNext(IMediaTrack track, IMediaTrack currentPlayedTrack) { if (IsShuffleEnabled) @@ -61,6 +69,12 @@ public Task PlayNext(IMediaTrack track, IMediaTrack currentPlayedTrack) public IList Tracks => IsShuffleEnabled ? _shuffledTracks : _queue.Tracks; + public bool HasPendingChanges + { + get => _queue.HasPendingChanges; + set => _queue.HasPendingChanges = value; + } + public bool IsSameQueue(IList newMediaTracks) { return _queue.IsSameQueue(newMediaTracks); diff --git a/BMM.Core/Translation/en/Translations.designer.cs b/BMM.Core/Translation/en/Translations.designer.cs index 3957d2dbb..76cd66ba2 100644 --- a/BMM.Core/Translation/en/Translations.designer.cs +++ b/BMM.Core/Translation/en/Translations.designer.cs @@ -206,6 +206,10 @@ public static class Translations public const string PlayerViewModel_WatchOnBCCMedia = nameof(PlayerViewModel_WatchOnBCCMedia); public const string PlayerViewModel_Read = nameof(PlayerViewModel_Read); public const string QueueViewModel_Title = nameof(QueueViewModel_Title); + public const string QueueViewModel_Delete = nameof(QueueViewModel_Delete); + public const string QueueViewModel_CannotRemoveFromQueue = nameof(QueueViewModel_CannotRemoveFromQueue); + public const string QueueViewModel_TrackRemovedFromQueue = nameof(QueueViewModel_TrackRemovedFromQueue); + public const string QueueViewModel_RemoveFromQueueOption = nameof(QueueViewModel_RemoveFromQueueOption); public const string SearchViewModel_Title = nameof(SearchViewModel_Title); public const string SearchViewModel_SearchHint = nameof(SearchViewModel_SearchHint); public const string SearchViewModel_SearchResults = nameof(SearchViewModel_SearchResults); diff --git a/BMM.Core/Translation/en/main.json b/BMM.Core/Translation/en/main.json index e13ad816e..9e19381d4 100644 --- a/BMM.Core/Translation/en/main.json +++ b/BMM.Core/Translation/en/main.json @@ -268,7 +268,11 @@ "Read": "Read" }, "QueueViewModel": { - "Title": "Queue" + "Title": "Queue", + "Delete": "Delete", + "CannotRemoveFromQueue": "Cannot remove currently playing track from the queue", + "TrackRemovedFromQueue": "Track has been removed from the queue", + "RemoveFromQueueOption": "Remove from queue" }, "SearchViewModel": { "Title": "Search", diff --git a/BMM.Core/ViewModels/OptionsListViewModel.cs b/BMM.Core/ViewModels/OptionsListViewModel.cs index 7e33710c3..3f5be9d8e 100644 --- a/BMM.Core/ViewModels/OptionsListViewModel.cs +++ b/BMM.Core/ViewModels/OptionsListViewModel.cs @@ -12,12 +12,15 @@ namespace BMM.Core.ViewModels { public class OptionsListViewModel : BaseViewModel, ITrackOptionsViewModel { + private const int TimeForDialogCloseInMillis = 250; + public OptionsListViewModel() { CloseOptionsCommand = new MvxCommand(() => CloseInteraction.Raise()); OptionSelectedCommand = new MvxAsyncCommand(async option => { await CloseCommand.ExecuteAsync(); + await Task.Delay(TimeForDialogCloseInMillis); option.ClickCommand.Execute(); }); } diff --git a/BMM.Core/ViewModels/QueueViewModel.cs b/BMM.Core/ViewModels/QueueViewModel.cs index c078f0373..9190b5891 100644 --- a/BMM.Core/ViewModels/QueueViewModel.cs +++ b/BMM.Core/ViewModels/QueueViewModel.cs @@ -3,14 +3,17 @@ using System.Threading.Tasks; using BMM.Api.Abstraction; using BMM.Api.Implementation.Models; +using BMM.Core.Extensions; using BMM.Core.Implementations.Factories.Tracks; using BMM.Core.Implementations.UI; +using BMM.Core.Messages.MediaPlayer; using BMM.Core.Models.POs.Base; using BMM.Core.Models.POs.Base.Interfaces; using BMM.Core.Models.POs.Tracks; using BMM.Core.NewMediaPlayer.Abstractions; using BMM.Core.ViewModels.Base; using MvvmCross; +using MvvmCross.Plugin.Messenger; namespace BMM.Core.ViewModels { @@ -18,18 +21,42 @@ public class QueueViewModel : DocumentsViewModel { private readonly IMediaQueue _mediaQueue; private readonly ITrackPOFactory _trackPOFactory; + private readonly IMediaPlayer _mediaPlayer; + private readonly IMvxMessenger _mvxMessenger; + private MvxSubscriptionToken _token; public QueueViewModel( IMediaQueue mediaQueue, - ITrackPOFactory trackPOFactory) + ITrackPOFactory trackPOFactory, + IMediaPlayer mediaPlayer, + IMvxMessenger mvxMessenger) { _mediaQueue = mediaQueue; _trackPOFactory = trackPOFactory; + _mediaPlayer = mediaPlayer; + _mvxMessenger = mvxMessenger; } + public override void ViewAppeared() + { + base.ViewAppeared(); + _token = _mvxMessenger.Subscribe(QueueChangedAction); + } + + public override void ViewDisappeared() + { + base.ViewDisappeared(); + _mvxMessenger.UnsubscribeSafe(_token); + } + + private async void QueueChangedAction(QueueChangedMessage queueChangedMessage) + { + await Refresh(); + } + protected override async Task DocumentAction(IDocumentPO item, IList list) { - await Mvx.IoCProvider.Resolve().Play(_mediaQueue.Tracks, ((TrackPO)item).Track); + await _mediaPlayer.Play(_mediaQueue.Tracks, ((TrackPO)item).Track); } public async override Task> LoadItems(CachePolicy policy = CachePolicy.UseCacheAndRefreshOutdated) diff --git a/BMM.UI.Android/Application/Adapters/QueueRecyclerAdapter.cs b/BMM.UI.Android/Application/Adapters/QueueRecyclerAdapter.cs new file mode 100644 index 000000000..b1fcc0a1d --- /dev/null +++ b/BMM.UI.Android/Application/Adapters/QueueRecyclerAdapter.cs @@ -0,0 +1,25 @@ +using _Microsoft.Android.Resource.Designer; +using Android.Graphics; +using Android.Views; +using AndroidX.RecyclerView.Widget; +using BMM.UI.Droid.Application.Adapters.Swipes; +using BMM.UI.Droid.Application.Extensions; +using BMM.UI.Droid.Application.ViewHolders; +using MvvmCross.Platforms.Android.Binding.BindingContext; + +namespace BMM.UI.Droid.Application.Adapters; + +public class QueueRecyclerAdapter : BaseSwipeMenuAdapter +{ + public QueueRecyclerAdapter(IMvxAndroidBindingContext bindingContext) : base(bindingContext) + { + } + + public override RecyclerView.ViewHolder OnCreateViewHolder(ViewGroup parent, int viewType) + { + var itemBindingContext = new MvxAndroidBindingContext(parent.Context, BindingContext.LayoutInflaterHolder); + var view = itemBindingContext.BindingInflate(viewType, parent, false); + view.SetBackgroundColor(parent.Context.GetColorFromResource(ResourceConstant.Color.background_one_color)); + return new QueueItemViewHolder(view, itemBindingContext, this); + } +} \ No newline at end of file diff --git a/BMM.UI.Android/Application/Adapters/Swipes/BaseSwipeMenuAdapter.cs b/BMM.UI.Android/Application/Adapters/Swipes/BaseSwipeMenuAdapter.cs new file mode 100644 index 000000000..a477eb9a9 --- /dev/null +++ b/BMM.UI.Android/Application/Adapters/Swipes/BaseSwipeMenuAdapter.cs @@ -0,0 +1,88 @@ +using Android.Views; +using AndroidX.RecyclerView.Widget; +using BMM.UI.Droid.Application.ViewHolders.Base; +using MvvmCross.DroidX.RecyclerView; +using MvvmCross.Platforms.Android.Binding.BindingContext; +using Object = Java.Lang.Object; + +namespace BMM.UI.Droid.Application.Adapters.Swipes +{ + public class BaseSwipeMenuAdapter : MvxRecyclerAdapter, ISwipeMenuAdapter, View.IOnScrollChangeListener + { + private readonly IList _visibleViewHolders = new List(); + private RecyclerView _recyclerView; + + public BaseSwipeMenuAdapter(IMvxAndroidBindingContext bindingContext) : base(bindingContext){} + + public SwipeMenuViewHolder GetViewHolder(View view) + => _visibleViewHolders.FirstOrDefault(x => x.ItemView == view); + + public void SetActiveMenu(SwipeMenuViewHolder swipeMenuViewHolder) + { + ResetMenusOnVisibleViewHolders(swipeMenuViewHolder); + } + + public void OnScrollChange(View v, int scrollX, int scrollY, int oldScrollX, int oldScrollY) + { + ResetMenusOnVisibleViewHolders(); + } + + public override void OnViewRecycled(Object holder) + { + base.OnViewRecycled(holder); + if (holder is SwipeMenuViewHolder swipeViewHolder) + _visibleViewHolders.Remove(swipeViewHolder); + } + + public override void OnBindViewHolder(RecyclerView.ViewHolder holder, int position) + { + base.OnBindViewHolder(holder, position); + + if (holder is SwipeMenuViewHolder swipeViewHolder) + _visibleViewHolders.Add(swipeViewHolder); + } + + public void ResetMenusOnVisibleViewHolders(SwipeMenuViewHolder active = null) + { + if (_recyclerView?.GetLayoutManager() is MvxGuardedLinearLayoutManager linearLayoutManager) + { + int first = linearLayoutManager.FindFirstVisibleItemPosition(); + int last = linearLayoutManager.FindLastVisibleItemPosition(); + for (int i = first; i <= last; i++) + { + if (_recyclerView.FindViewHolderForAdapterPosition(i) is SwipeMenuViewHolder swipeMenuViewHolder + && swipeMenuViewHolder != active) + swipeMenuViewHolder.HideMenusIfNeeded(); + + } + } + else + { + foreach (var viewHolder in _visibleViewHolders.Where(x => x!= active)) + viewHolder.HideMenusIfNeeded(); + } + } + + public override void OnViewDetachedFromWindow(Object holder) + { + if (holder is SwipeMenuViewHolder swipeMenuViewHolder + && _visibleViewHolders.Contains(swipeMenuViewHolder)) + _visibleViewHolders.Remove(swipeMenuViewHolder); + + base.OnViewDetachedFromWindow(holder); + } + + public override void OnAttachedToRecyclerView(RecyclerView recyclerView) + { + base.OnAttachedToRecyclerView(recyclerView); + _recyclerView = recyclerView; + } + + public override void OnDetachedFromRecyclerView(RecyclerView recyclerView) + { + _visibleViewHolders.Clear(); + _recyclerView = null; + base.OnDetachedFromRecyclerView(recyclerView); + } + } +} diff --git a/BMM.UI.Android/Application/Adapters/Swipes/ISwipeMenuAdapter.cs b/BMM.UI.Android/Application/Adapters/Swipes/ISwipeMenuAdapter.cs new file mode 100644 index 000000000..0a7b701c1 --- /dev/null +++ b/BMM.UI.Android/Application/Adapters/Swipes/ISwipeMenuAdapter.cs @@ -0,0 +1,9 @@ +using BMM.UI.Droid.Application.ViewHolders.Base; + +namespace BMM.UI.Droid.Application.Adapters.Swipes +{ + public interface ISwipeMenuAdapter + { + void SetActiveMenu(SwipeMenuViewHolder swipeMenuViewHolder); + } +} \ No newline at end of file diff --git a/BMM.UI.Android/Application/Constants/States.cs b/BMM.UI.Android/Application/Constants/States.cs new file mode 100644 index 000000000..8e0a8601a --- /dev/null +++ b/BMM.UI.Android/Application/Constants/States.cs @@ -0,0 +1,18 @@ +namespace BMM.UI.Droid.Application.Constants; + +public static class States +{ + public static int[] Pressed => new[] + { + Android.Resource.Attribute.StatePressed + }; + + public static int[] Disabled => new[] + { + -Android.Resource.Attribute.StateEnabled + }; + + public static int[] Default => new int[] + { + }; +} \ No newline at end of file diff --git a/BMM.UI.Android/Application/CustomViews/Swipes/SwipeFrameLayout.cs b/BMM.UI.Android/Application/CustomViews/Swipes/SwipeFrameLayout.cs new file mode 100644 index 000000000..20aa8d853 --- /dev/null +++ b/BMM.UI.Android/Application/CustomViews/Swipes/SwipeFrameLayout.cs @@ -0,0 +1,61 @@ +using Android.Content; +using Android.Runtime; +using Android.Util; +using Android.Views; + +namespace BMM.UI.Droid.Application.CustomViews.Swipes +{ + public class SwipeFrameLayout : FrameLayout + { + private const float MinDeltaForSwipe = 10; + + private float? _touchPointX; + + protected SwipeFrameLayout(IntPtr javaReference, JniHandleOwnership transfer) : base(javaReference, transfer) + { + } + + public SwipeFrameLayout(Context context) : base(context) + { + } + + public SwipeFrameLayout(Context context, IAttributeSet? attrs) : base(context, attrs) + { + } + + public SwipeFrameLayout(Context context, IAttributeSet? attrs, int defStyleAttr) : base(context, attrs, defStyleAttr) + { + } + + public SwipeFrameLayout(Context context, IAttributeSet? attrs, int defStyleAttr, int defStyleRes) : base(context, attrs, defStyleAttr, defStyleRes) + { + } + + public override bool OnInterceptTouchEvent(MotionEvent? ev) + { + switch (ev.Action) + { + case MotionEventActions.Down: + SetTouchPointX(ev); + break; + case MotionEventActions.Move: + float delta = GetDeltaX(ev, _touchPointX); + if (Math.Abs(delta) > MinDeltaForSwipe) + return true; + + break; + } + return base.OnInterceptTouchEvent(ev); + } + + private float GetDeltaX(MotionEvent? ev, float? touchPointX) + => touchPointX is null + ? 0 + : ev.GetX() - touchPointX.Value; + + private void SetTouchPointX(MotionEvent? ev) + { + _touchPointX = ev.GetX(); + } + } +} \ No newline at end of file diff --git a/BMM.UI.Android/Application/CustomViews/Swipes/SwipeMenuControl.cs b/BMM.UI.Android/Application/CustomViews/Swipes/SwipeMenuControl.cs new file mode 100644 index 000000000..fbe5bb50f --- /dev/null +++ b/BMM.UI.Android/Application/CustomViews/Swipes/SwipeMenuControl.cs @@ -0,0 +1,198 @@ +using Android.Animation; +using Android.Content; +using Android.Runtime; +using Android.Util; +using Android.Views; +using BMM.Core.Models.Enums; +using BMM.UI.Droid.Application.ViewHolders.Base; +using LayoutDirection = Android.Views.LayoutDirection; + +namespace BMM.UI.Droid.Application.CustomViews.Swipes +{ + public class SwipeMenuControl : LinearLayout, ValueAnimator.IAnimatorUpdateListener + { + public const int MinAnimationThreshold = 15; + + private ValueAnimator _animator; + private readonly IList _items = new List(); + private SwipeMenuViewHolder _viewHolder; + + public SwipeMenuControl(Context context, IAttributeSet attrs) : base(context, attrs) + { + } + + protected SwipeMenuControl(IntPtr javaReference, JniHandleOwnership transfer) : base(javaReference, transfer) + { + } + + public SwipeMenuControl(int itemWidth, SwipePlacement placement, Context context, IAttributeSet attrs) : base( + context, + attrs) + { + ItemWidth = itemWidth; + Orientation = Orientation.Horizontal; + Placement = placement; + + if (Placement == SwipePlacement.Right) + { + LayoutParameters = new FrameLayout.LayoutParams( + LayoutParams.MatchParent, + LayoutParams.MatchParent, + GravityFlags.Right); + LayoutDirection = LayoutDirection.Rtl; + } + } + + public int AnimatedChildIndex { get; set; } = 0; + public bool IsOpen { get; set; } + public SwipePlacement Placement { get; set; } + public int ItemWidth { get; set; } + public int ItemWidthInPx => (int)(DpFactor * ItemWidth); + public float DpFactor => Resources.DisplayMetrics.Density; + public int FullSizeWidth => ItemWidthInPx * _items.Count(x => x.Available); + public int EdgeSnappingPoint => FullSizeWidth > ItemWidthInPx ? FullSizeWidth / 2 : (int)(ItemWidthInPx * 0.8f); + public int EdgeClosingPoint => FullSizeWidth - EdgeSnappingPoint; + public int FullSwipeSnappingPoint => (int)((Parent as ViewGroup).Width * 0.7f); + public bool AllowFullSwipe { get; set; } = true; + + public bool FullSwipeAvailable + => AllowFullSwipe && (_items.FirstOrDefault(x => x.Available)?.CanExecuteFullSwipe ?? false); + + public void AddSwipeMenuItem(SwipeMenuView item) + { + _items.Add(item); + + var layoutParams = new LayoutParams(ItemWidthInPx, LayoutParams.MatchParent); + if (ChildCount == 0) + { + item.SetMinimumWidth(ItemWidthInPx); + } + + item.LayoutParameters = layoutParams; + AddView(item); + } + + public void ExecuteFullSwipe(object dataContext) + { + if (!(_items.FirstOrDefault(x => x.Available) is { } firstItem)) + return; + + var command = firstItem.FullSwipeCommand ?? firstItem.ClickCommand; + if (command?.CanExecute(dataContext) ?? false) + command.Execute(dataContext); + } + + public int ResizeWidth(int width, bool animate = false) + { + if (!FullSwipeAvailable) + width = Math.Min(width, FullSizeWidth); + + if (Width == 0) + IsOpen = false; + else if (Width >= FullSizeWidth) + IsOpen = true; + + if (!animate || Math.Abs(Width - width) < MinAnimationThreshold) + { + CancelAnimatorIfNeeded(); + SetWidth(width); + return width; + } + + CancelAnimatorIfNeeded(); + _animator = ValueAnimator.OfInt(Width, width); + _animator.AddUpdateListener(this); + _animator.AnimationEnd += AnimatorOnAnimationEnd; + _animator.Start(); + return width; + } + + private void AnimatorOnAnimationEnd(object sender, EventArgs e) + { + _animator = null; + } + + private void CancelAnimatorIfNeeded() + { + if (_animator != null) + { + _animator.Cancel(); + _animator.AnimationEnd -= AnimatorOnAnimationEnd; + _animator.RemoveAllUpdateListeners(); + _animator = null; + } + } + + private void SetWidth(int width) + { + var layoutParams = LayoutParameters; + layoutParams.Width = width; + LayoutParameters = layoutParams; + + var childLayoutPrams = (LinearLayout.LayoutParams)GetChildAt(AnimatedChildIndex)?.LayoutParameters; + + if (childLayoutPrams == null) + return; + + if (width > FullSizeWidth) + { + childLayoutPrams.Weight = 1; + } + else + { + childLayoutPrams.Weight = 0; + } + + if (width > FullSwipeSnappingPoint) + { + HideAllChildrenExceptFirstOne(); + } + else + { + MakeSureAllChildrenVisible(); + } + + GetChildAt(AnimatedChildIndex).LayoutParameters = childLayoutPrams; + } + + public void MakeSureAllChildrenVisible() + { + SetChildrenVisibility(ViewStates.Visible); + + SwipeMenuView firstItem = null; + if (_items.Any() && (firstItem = _items.FirstOrDefault(x => x.Available)) != null) + { + AnimatedChildIndex = _items.IndexOf(firstItem); + } + } + + private void SetChildrenVisibility(ViewStates viewState) + { + for (int i = 0; i < ChildCount; i++) + { + var menuItem = GetChildAt(i) as SwipeMenuView; + menuItem.Visibility = menuItem.Available ? viewState : ViewStates.Gone; + } + } + + public void HideAllChildrenExceptFirstOne() + { + SetChildrenVisibility(ViewStates.Gone); + + if (ChildCount > 0) + { + var menuItem = _items.FirstOrDefault(x => x.Available); + menuItem.Visibility = ViewStates.Visible; + } + } + + public void OnAnimationUpdate(ValueAnimator animation) + { + SetWidth((int)animation.AnimatedValue); + } + + public void SetViewHolder(SwipeMenuViewHolder viewHolder) => _viewHolder = viewHolder; + public void RequestHideMenu() => _viewHolder?.HideMenusIfNeeded(); + public void RequestShowMenu(SwipePlacement placement) => _viewHolder?.ShowMenu(placement); + } +} \ No newline at end of file diff --git a/BMM.UI.Android/Application/CustomViews/Swipes/SwipeMenuView.cs b/BMM.UI.Android/Application/CustomViews/Swipes/SwipeMenuView.cs new file mode 100644 index 000000000..efde5f3c7 --- /dev/null +++ b/BMM.UI.Android/Application/CustomViews/Swipes/SwipeMenuView.cs @@ -0,0 +1,154 @@ +using _Microsoft.Android.Resource.Designer; +using Android.Content; +using Android.Graphics; +using Android.Graphics.Drawables; +using Android.Runtime; +using Android.Util; +using Android.Views; +using BMM.Core.Constants; +using BMM.UI.Droid.Application.Constants; +using MvvmCross.Binding.BindingContext; +using MvvmCross.Commands; +using MvvmCross.Platforms.Android.Binding.BindingContext; +using MvvmCross.Platforms.Android.Views; + +namespace BMM.UI.Droid.Application.CustomViews.Swipes +{ + public class SwipeMenuView : + RelativeLayout, + View.IOnClickListener, + IMvxBindingContextOwner + { + private const int DefaultLayout = ResourceConstant.Layout.view_swipe_menu_simple; + + private object _dataContext; + private IMvxCommand _clickCommand; + private IMvxCommand _fullSwipeCommand; + + protected SwipeMenuView(IntPtr javaReference, JniHandleOwnership transfer) : base(javaReference, transfer) + { + } + + public SwipeMenuView(Context context, int layoutId = DefaultLayout) : base(context) + { + InflateLayout(context, layoutId); + } + + public SwipeMenuView(Context context) : base(context) + { + InflateLayout(context, DefaultLayout); + } + + public SwipeMenuView(Context context, IAttributeSet attrs) : base(context, attrs) + { + } + + public SwipeMenuView(Context context, IAttributeSet attrs, int defStyleAttr) : base( + context, + attrs, + defStyleAttr) + { + } + + public SwipeMenuView(Context context, IAttributeSet attrs, int defStyleAttr, int defStyleRes) : base( + context, + attrs, + defStyleAttr, + defStyleRes) + { + } + + public TextView TitleLabel { get; set; } + + public IMvxCommand ClickCommand + { + get => _clickCommand; + set + { + _clickCommand = value; + RefreshEnabledState(); + } + } + + public IMvxCommand FullSwipeCommand + { + get => _fullSwipeCommand; + set + { + _fullSwipeCommand = value; + RefreshEnabledState(); + } + } + + private void InflateLayout(Context context, int layoutId) + { + var inflater = (LayoutInflater)context.GetSystemService(Context.LayoutInflaterService); + BindingContext = new MvxAndroidBindingContext(context, new MvxSimpleLayoutInflaterHolder(inflater)); + inflater.Inflate(layoutId, this); + Clickable = true; + + SetOnClickListener(this); + + TitleLabel = FindViewById(ResourceConstant.Id.TitleLabel); + } + + public void SetColors(Color backgroundColor) + { + var backgroundStateListDrawable = new StateListDrawable(); + + backgroundStateListDrawable.SetExitFadeDuration( + ViewConstants.SwiftAnimationDurationInMilliseconds); + + backgroundStateListDrawable.AddState( + States.Default, + new ColorDrawable(backgroundColor)); + + Background = backgroundStateListDrawable; + backgroundStateListDrawable.JumpToCurrentState(); + } + + public bool Available { get; set; } = true; + + public object DataContext + { + get => _dataContext; + set + { + _dataContext = value; + if (BindingContext != null) + BindingContext.DataContext = value; + } + } + + public bool CanExecute => _clickCommand?.CanExecute(_dataContext) ?? false; + + public bool CanExecuteFullSwipe => _fullSwipeCommand?.CanExecute(_dataContext) + ?? _clickCommand?.CanExecute(_dataContext) + ?? false; + + public IMvxBindingContext BindingContext { get; set; } + + public void RefreshEnabledState() + { + if (Enabled == CanExecute) + return; + + Enabled = CanExecute; + } + + public void OnClick(View v) + { + if (!CanExecute) + return; + + ClickCommand?.Execute(_dataContext); + HideMenu(); + } + + private void HideMenu() + { + if(Parent is SwipeMenuControl swipeMenuControl) + swipeMenuControl.RequestHideMenu(); + } + } +} \ No newline at end of file diff --git a/BMM.UI.Android/Application/Fragments/QueueFragment.cs b/BMM.UI.Android/Application/Fragments/QueueFragment.cs index 45aff129a..7c225cfb2 100644 --- a/BMM.UI.Android/Application/Fragments/QueueFragment.cs +++ b/BMM.UI.Android/Application/Fragments/QueueFragment.cs @@ -1,6 +1,11 @@ -using Android.Runtime; +using _Microsoft.Android.Resource.Designer; +using Android.Runtime; +using Android.Views; using BMM.Core.ViewModels; +using BMM.UI.Droid.Application.Adapters; using BMM.UI.Droid.Application.Fragments.Base; +using MvvmCross.DroidX.RecyclerView; +using MvvmCross.Platforms.Android.Binding.BindingContext; using MvvmCross.Platforms.Android.Presenters.Attributes; namespace BMM.UI.Droid.Application.Fragments @@ -10,5 +15,15 @@ namespace BMM.UI.Droid.Application.Fragments public class QueueFragment : BaseDialogFragment { protected override int FragmentId => Resource.Layout.fragment_queue; + + public override View OnCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) + { + var view = base.OnCreateView(inflater, container, savedInstanceState); + + var recyclerView = view.FindViewById(ResourceConstant.Id.QueueRecyclerView); + recyclerView.Adapter = new QueueRecyclerAdapter((IMvxAndroidBindingContext)BindingContext); + + return view; + } } } \ No newline at end of file diff --git a/BMM.UI.Android/Application/NewMediaPlayer/Controller/AndroidMediaPlayer.cs b/BMM.UI.Android/Application/NewMediaPlayer/Controller/AndroidMediaPlayer.cs index e3f709257..67708e6b9 100644 --- a/BMM.UI.Android/Application/NewMediaPlayer/Controller/AndroidMediaPlayer.cs +++ b/BMM.UI.Android/Application/NewMediaPlayer/Controller/AndroidMediaPlayer.cs @@ -5,6 +5,7 @@ using BMM.Api.Abstraction; using BMM.Api.Framework; using BMM.Core.Extensions; +using BMM.Core.Helpers; using BMM.Core.Implementations.Analytics; using BMM.Core.Messages.MediaPlayer; using BMM.Core.NewMediaPlayer.Abstractions; @@ -43,6 +44,7 @@ public class AndroidMediaPlayer : MediaBrowserCompat.ConnectionCallback, IPlatfo private long _lastPosition; private decimal _lastPlaybackSpeed; private SemaphoreSlim _connectSemaphoreSlim = new(1, 1); + private int _lastMediaId; public AndroidMediaPlayer( IMediaQueue mediaQueue, @@ -183,6 +185,44 @@ public async Task ForceReloadQueueAndPlayCurrentTrack() var controls = _mediaController.GetTransportControls(); controls!.PlayFromMediaId(CurrentTrack.Id.ToString(), null); } + + public void ReloadQueueIfNeeded(MediaItem desiredMediaItem) + { + if (!_mediaQueue.HasPendingChanges || desiredMediaItem == null || CurrentTrack == null) + return; + + int positionOfCurrentMediaItem = GetMediaItemIndex(CurrentTrack.Id.ToString()); + int positionOfDesiredMediaItem = GetMediaItemIndex(desiredMediaItem.MediaId); + + var trackToPlay = _mediaQueue + .Tracks + .FirstOrDefault(t => t.Id.ToString() == desiredMediaItem.MediaId); + + if (trackToPlay == null) + { + var currentTrack = _mediaQueue + .Tracks + .FirstOrDefault(c => c.Id == CurrentTrack.Id); + + bool shouldForward = positionOfDesiredMediaItem > positionOfCurrentMediaItem; + + trackToPlay = shouldForward + ? _mediaQueue.Tracks.FindNextAfter(currentTrack) + : _mediaQueue.Tracks.FindPreviousBefore(currentTrack); + } + + var controls = _mediaController.GetTransportControls(); + controls!.PlayFromMediaId(trackToPlay.Id.ToString(), null); + + _mediaQueue.HasPendingChanges = false; + } + + private int GetMediaItemIndex(string id) + { + return _mediaController + .Queue + .FindIndex(i => i.Description.MediaId == id); + } public async Task RecoverQueue(IList mediaTracks, IMediaTrack currentTrack, long startTimeInMs = 0) { @@ -266,6 +306,17 @@ public void ChangePlaybackSpeed(decimal playbackSpeed) public decimal CurrentPlaybackSpeed { get; private set; } = PlayerConstants.NormalPlaybackSpeed; + public async Task DeleteFromQueue(IMediaTrack track) + { + await Task.CompletedTask; + + if (_mediaController == null) + return; + + _mediaQueue.Delete(track); + _lastMediaId = CurrentTrack.Id; + } + public Task AddToEndOfQueue(IMediaTrack track, string playbackOrigin, bool ignoreIfAlreadyAdded = false) { return AddToQueueAtIndex(track); diff --git a/BMM.UI.Android/Application/NewMediaPlayer/CustomShuffleOrder.cs b/BMM.UI.Android/Application/NewMediaPlayer/CustomShuffleOrder.cs index 67027059c..f30e26562 100644 --- a/BMM.UI.Android/Application/NewMediaPlayer/CustomShuffleOrder.cs +++ b/BMM.UI.Android/Application/NewMediaPlayer/CustomShuffleOrder.cs @@ -107,12 +107,15 @@ public IShuffleOrder CloneAndInsert(int insertionIndex, int insertionCount) public IShuffleOrder CloneAndRemove(int indexFrom, int indexToExclusive) { - int[] newShuffled = new int[indexToExclusive - indexFrom]; - var startIndex = indexFrom; - while (startIndex < indexToExclusive) + int[] newShuffled = new int[_shuffled.Length - (indexToExclusive - indexFrom)]; + int j = 0; + + for (int i = 0; i < _shuffled.Length; i++) { - newShuffled[startIndex] = _shuffled[startIndex]; - startIndex++; + if (i >= indexFrom && i < indexToExclusive) + continue; + + newShuffled[j++] = _shuffled[i]; } return new CustomShuffleOrder(_temporaryStartIndex, _useTemporaryStartIndex, newShuffled, new Random(_random.Next(int.MaxValue))); diff --git a/BMM.UI.Android/Application/NewMediaPlayer/Listeners/PlayerListener.cs b/BMM.UI.Android/Application/NewMediaPlayer/Listeners/PlayerListener.cs index 33a78f2ee..d01354780 100644 --- a/BMM.UI.Android/Application/NewMediaPlayer/Listeners/PlayerListener.cs +++ b/BMM.UI.Android/Application/NewMediaPlayer/Listeners/PlayerListener.cs @@ -1,8 +1,7 @@ -using System.Diagnostics; -using System.Timers; using BMM.Core.Messages.MediaPlayer; +using BMM.Core.NewMediaPlayer.Abstractions; using BMM.Core.NewMediaPlayer.Constants; -using BMM.UI.Droid.Utils; +using BMM.UI.Droid.Application.NewMediaPlayer.Controller; using Com.Google.Android.Exoplayer2; using Com.Google.Android.Exoplayer2.Audio; using Com.Google.Android.Exoplayer2.Metadata; @@ -20,11 +19,13 @@ public class PlayerListener : Java.Lang.Object, IPlayer.IListener private double _lastPosition; private readonly IMvxMessenger _mvxMessenger; private readonly MvxSubscriptionToken _token; + private readonly AndroidMediaPlayer _mediaPlayer; public PlayerListener(IExoPlayer playerInstance) { _playerInstance = playerInstance; _mvxMessenger = Mvx.IoCProvider.Resolve(); + _mediaPlayer = (AndroidMediaPlayer)Mvx.IoCProvider.Resolve(); _token = _mvxMessenger.Subscribe((message => _lastPosition = message.CurrentPosition)); } @@ -74,6 +75,7 @@ public void OnMaxSeekToPreviousPositionChanged(long maxSeekToPreviousPositionMs) public void OnMediaItemTransition(MediaItem mediaItem, int reason) { + _mediaPlayer.ReloadQueueIfNeeded(mediaItem); _mvxMessenger.Publish(new CurrentTrackWillChangeMessage( this, _lastPosition, diff --git a/BMM.UI.Android/Application/NewMediaPlayer/Notification/MusicServiceMediaCallback.cs b/BMM.UI.Android/Application/NewMediaPlayer/Notification/MusicServiceMediaCallback.cs index b972eb9c6..351b04213 100644 --- a/BMM.UI.Android/Application/NewMediaPlayer/Notification/MusicServiceMediaCallback.cs +++ b/BMM.UI.Android/Application/NewMediaPlayer/Notification/MusicServiceMediaCallback.cs @@ -1,4 +1,5 @@ using System; +using Android.OS.Storage; using Android.Support.V4.Media; using Android.Support.V4.Media.Session; @@ -21,6 +22,11 @@ public override void OnPlaybackStateChanged(PlaybackStateCompat state) OnPlaybackStateChangedImpl?.Invoke(state); } + public override void OnQueueChanged(IList queue) + { + base.OnQueueChanged(queue); + } + public override void OnMetadataChanged(MediaMetadataCompat meta) { OnMetadataChangedImpl?.Invoke(meta); diff --git a/BMM.UI.Android/Application/NewMediaPlayer/Playback/SingleMediaSourceFactory.cs b/BMM.UI.Android/Application/NewMediaPlayer/Playback/SingleMediaSourceFactory.cs index 910cf6072..7c1300b9f 100644 --- a/BMM.UI.Android/Application/NewMediaPlayer/Playback/SingleMediaSourceFactory.cs +++ b/BMM.UI.Android/Application/NewMediaPlayer/Playback/SingleMediaSourceFactory.cs @@ -1,11 +1,13 @@ using Android.Content; using BMM.Api.Abstraction; using BMM.Core.Implementations.Security; +using BMM.Core.Implementations.UI; using Com.Google.Android.Exoplayer2; using Com.Google.Android.Exoplayer2.Drm; using Com.Google.Android.Exoplayer2.Source; using Com.Google.Android.Exoplayer2.Upstream; using Com.Google.Android.Exoplayer2.Util; +using MvvmCross; namespace BMM.UI.Droid.Application.NewMediaPlayer.Playback { diff --git a/BMM.UI.Android/Application/ViewHolders/Base/SwipeMenuViewHolder.cs b/BMM.UI.Android/Application/ViewHolders/Base/SwipeMenuViewHolder.cs new file mode 100644 index 000000000..dcb618521 --- /dev/null +++ b/BMM.UI.Android/Application/ViewHolders/Base/SwipeMenuViewHolder.cs @@ -0,0 +1,318 @@ +using Android.Animation; +using Android.Content; +using Android.Runtime; +using Android.Views; +using BMM.Core.Constants; +using BMM.Core.Extensions; +using BMM.Core.Models.Enums; +using BMM.UI.Droid.Application.Adapters.Swipes; +using BMM.UI.Droid.Application.CustomViews.Swipes; +using MvvmCross.Commands; +using MvvmCross.DroidX.RecyclerView; +using MvvmCross.Platforms.Android.Binding.BindingContext; + +namespace BMM.UI.Droid.Application.ViewHolders.Base +{ + public abstract class SwipeMenuViewHolder : MvxRecyclerViewHolder, View.IOnTouchListener + { + private const int ItemWidth = 60; + + protected readonly ISwipeMenuAdapter SwipeMenuAdapter; + public const int MinSwipeThreshold = 10; + public const float MinStuckThreshold = 0.9f; + public const int AnimationDuration = 300; + + private View _mainItemView; + private ValueAnimator _animator; + private float _touchStartingPointX; + private float _touchInitialOffsetX; + private bool _menuInitialized; + private bool _touchMoved; + private bool _touchStarted; + + public SwipeMenuControl LeftMenu { get; private set; } + public SwipeMenuControl RightMenu { get; private set; } + public bool IsSwipingEnabled { get; set; } = true; + + protected Context Context { get; } + + public SwipeMenuViewHolder( + View itemView, + IMvxAndroidBindingContext context, + ISwipeMenuAdapter swipeMenuAdapter) + : base(itemView, context) + { + SwipeMenuAdapter = swipeMenuAdapter; + Context = itemView.Context; + WrapView(); + } + + private void WrapView() + { + var frame = new SwipeFrameLayout(Android.App.Application.Context) + { + LayoutParameters = new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MatchParent, + ViewGroup.LayoutParams.WrapContent) + }; + LeftMenu = new SwipeMenuControl(ItemWidth, SwipePlacement.Left, Android.App.Application.Context, null); + RightMenu = new SwipeMenuControl(ItemWidth, SwipePlacement.Right, Android.App.Application.Context, null); + + frame.AddView(LeftMenu); + frame.AddView(RightMenu); + frame.AddView(ItemView); + + LeftMenu.Visibility = ViewStates.Gone; + RightMenu.Visibility = ViewStates.Gone; + + LeftMenu.SetViewHolder(this); + RightMenu.SetViewHolder(this); + + _mainItemView = ItemView; + ItemView = frame; + ItemView.SetOnTouchListener(this); + } + + public SwipeMenuViewHolder(IntPtr handle, JniHandleOwnership ownership) : base(handle, ownership) + { + } + + protected abstract void SetupMenuAndBind(); + public bool IsMenuShown => _mainItemView.TranslationX != 0; + + public void Reset() + { + if (!_menuInitialized + || (LeftMenu.Visibility == ViewStates.Gone && RightMenu.Visibility == ViewStates.Gone)) + return; + + _animator?.Cancel(); + _mainItemView.TranslationX = 0; + ResetMenu(LeftMenu); + ResetMenu(RightMenu); + } + + private void ResetMenu(SwipeMenuControl swipeMenuControl) + { + swipeMenuControl.ResizeWidth(0); + swipeMenuControl.SetViewHolder(this); + swipeMenuControl.Visibility = ViewStates.Gone; + } + + public Task HideMenusIfNeeded() + { + if (Math.Abs(_mainItemView.TranslationX) > MinStuckThreshold) + { + var taskSource = new TaskCompletionSource(); + AnimateTo(0, () => taskSource.SetResult(true)); + return taskSource.Task; + } + + return Task.CompletedTask; + } + + public void ShowMenu(SwipePlacement placement) + { + if (placement == SwipePlacement.Left) + MoveByOffset(LeftMenu.FullSizeWidth); + else + MoveByOffset(RightMenu.FullSizeWidth); + } + + public void MoveByOffset(int offsetX) + { + if (!_menuInitialized) + { + SetupMenuAndBind(); + _menuInitialized = true; + } + + if (offsetX > 0) + { + RightMenu.Visibility = ViewStates.Gone; + LeftMenu.Visibility = ViewStates.Visible; + LeftMenu.MakeSureAllChildrenVisible(); + offsetX = LeftMenu.ResizeWidth(offsetX); + } + else + { + RightMenu.Visibility = ViewStates.Visible; + LeftMenu.Visibility = ViewStates.Gone; + RightMenu.MakeSureAllChildrenVisible(); + offsetX = -RightMenu.ResizeWidth(-offsetX); + } + + _mainItemView.TranslationX = offsetX; + } + + public override void OnAttachedToWindow() + { + if (_menuInitialized) + Reset(); + + base.OnAttachedToWindow(); + } + + public bool OnTouch(View v, MotionEvent e) + { + if (!IsSwipingEnabled) + return false; + + switch (e.ActionMasked) + { + case MotionEventActions.Down: + TouchStarted(e.RawX); + break; + case MotionEventActions.Move: + { + TouchMoved(e.RawX); + break; + } + case MotionEventActions.Up: + case MotionEventActions.Cancel: + { + TouchEnded(e.RawX) + .FireAndForget(); + + break; + } + } + + return _touchMoved; + } + + private async Task TouchEnded(float x) + { + _touchStarted = false; + if (Math.Abs(_mainItemView.TranslationX) > MinStuckThreshold) + { + float offset = x - _touchStartingPointX + _touchInitialOffsetX; + await SnapToNearestView(offset); + } + } + + private void TouchMoved(float x) + { + if (!_touchStarted) + { + TouchStarted(x); + return; + } + + var offset = x - _touchStartingPointX; + if (Math.Abs(offset) > MinSwipeThreshold) + { + SwipeStartedCommand?.Execute(); + ItemView.Parent?.RequestDisallowInterceptTouchEvent(true); + MoveByOffset((int)(offset + _touchInitialOffsetX)); + _touchMoved = true; + } + } + + public IMvxCommand SwipeStartedCommand { get; set; } + + private void TouchStarted(float x) + { + _touchStarted = true; + _touchStartingPointX = x; + _touchInitialOffsetX = _mainItemView.TranslationX; + _touchMoved = false; + SwipeMenuAdapter.SetActiveMenu(this); + } + + private Task SnapToNearestView(float offset) + { + var taskSource = new TaskCompletionSource(); + if (offset > 0) + { + if ((!LeftMenu.IsOpen && offset < LeftMenu.EdgeSnappingPoint) + || (LeftMenu.IsOpen && offset < LeftMenu.EdgeClosingPoint)) + { + AnimateTo(0, () => taskSource.SetResult(true)); + } + else if (offset < LeftMenu.FullSwipeSnappingPoint + || !LeftMenu.FullSwipeAvailable) + { + AnimateTo(LeftMenu.FullSizeWidth, () => taskSource.SetResult(true)); + } + else + { + AnimateTo( + ItemView.Width, + () => + { + ExecuteFullSwipe(LeftMenu); + taskSource.SetResult(true); + }); + } + } + else + { + offset = -offset; + if ((!RightMenu.IsOpen && offset < RightMenu.EdgeSnappingPoint) + || (RightMenu.IsOpen && offset < RightMenu.EdgeClosingPoint)) + { + AnimateTo(0, () => taskSource.SetResult(true)); + } + else if (offset < RightMenu.FullSwipeSnappingPoint || !RightMenu.FullSwipeAvailable) + { + AnimateTo(-RightMenu.FullSizeWidth, () => taskSource.SetResult(true)); + } + else + { + AnimateTo( + -ItemView.Width, + () => + { + ExecuteFullSwipe(RightMenu); + taskSource.SetResult(true); + }); + } + } + + return taskSource.Task; + } + + private void ExecuteFullSwipe(SwipeMenuControl swipeMenuControl) + { + swipeMenuControl.ExecuteFullSwipe(DataContext); + Reset(); + } + + private void AnimateTo(float destination, Action animationEnded = null) + { + _animator?.Cancel(); + _animator = ValueAnimator.OfFloat(_mainItemView.TranslationX, destination); + _animator.SetDuration(AnimationDuration); + _animator.Update += AnimatorOnUpdate; + _animator.AnimationEnd += (sender, args) => + { + _mainItemView.TranslationX = destination; + if (destination == 0) + { + LeftMenu.ResizeWidth(0); + RightMenu.ResizeWidth(0); + } + + _animator = null; + animationEnded?.Invoke(); + }; + _animator.Start(); + } + + private void AnimatorOnUpdate(object sender, ValueAnimator.AnimatorUpdateEventArgs e) + { + float value = (float)e.Animation.AnimatedValue; + if (value > 0) + { + LeftMenu.ResizeWidth((int)value); + } + else + { + RightMenu.ResizeWidth(-(int)value); + } + + _mainItemView.TranslationX = value; + } + } +} \ No newline at end of file diff --git a/BMM.UI.Android/Application/ViewHolders/QueueItemViewHolder.cs b/BMM.UI.Android/Application/ViewHolders/QueueItemViewHolder.cs new file mode 100644 index 000000000..fad73b1b1 --- /dev/null +++ b/BMM.UI.Android/Application/ViewHolders/QueueItemViewHolder.cs @@ -0,0 +1,60 @@ +using _Microsoft.Android.Resource.Designer; +using Android.Graphics; +using Android.Runtime; +using Android.Views; +using BMM.Core.Helpers; +using BMM.Core.Models.POs.Tracks; +using BMM.Core.Translation; +using BMM.UI.Droid.Application.Adapters.Swipes; +using BMM.UI.Droid.Application.CustomViews.Swipes; +using BMM.UI.Droid.Application.Extensions; +using BMM.UI.Droid.Application.ViewHolders.Base; +using MvvmCross.Binding.BindingContext; +using MvvmCross.Platforms.Android.Binding.BindingContext; + +namespace BMM.UI.Droid.Application.ViewHolders; + +public class QueueItemViewHolder : SwipeMenuViewHolder +{ + public QueueItemViewHolder( + View itemView, + IMvxAndroidBindingContext context, + ISwipeMenuAdapter swipeMenuAdapter) : base(itemView, + context, + swipeMenuAdapter) + { + } + + public QueueItemViewHolder(IntPtr handle, JniHandleOwnership ownership) : base(handle, ownership) + { + } + + protected override void SetupMenuAndBind() + { + var set = this.CreateBindingSet(); + + RightMenu.AddSwipeMenuItem(Create(set)); + LeftMenu.AddSwipeMenuItem(Create(set)); + + set.Apply(); + } + + private SwipeMenuView Create(MvxFluentBindingDescriptionSet set) + { + var buy = new SwipeMenuView(Context); + + set.Bind(buy.TitleLabel) + .To(po => po.TextSource[Translations.QueueViewModel_Delete]); + + set.Bind(buy) + .For(v => v.ClickCommand) + .To(po => po.DeleteFromQueueCommand); + + set.Bind(buy) + .For(v => v.FullSwipeCommand) + .To(po => po.DeleteFromQueueCommand); + + buy.SetColors(Context.GetColorFromResource(ResourceConstant.Color.radio_color)); + return buy; + } +} \ No newline at end of file diff --git a/BMM.UI.Android/Resources/layout/fragment_queue.axml b/BMM.UI.Android/Resources/layout/fragment_queue.axml index 18cd86d61..dd47dd91d 100644 --- a/BMM.UI.Android/Resources/layout/fragment_queue.axml +++ b/BMM.UI.Android/Resources/layout/fragment_queue.axml @@ -17,7 +17,7 @@ android:background="@color/background_one_color" local:layout_constraintTop_toBottomOf="@+id/Toolbar" local:layout_constraintBottom_toBottomOf="parent" - local:MvxTemplateSelector="@string/document_templateselector" + local:MvxItemTemplate="@layout/listitem_track" local:MvxBind=" ItemsSource Documents; ItemClick DocumentSelectedCommand" /> diff --git a/BMM.UI.Android/Resources/layout/view_swipe_menu.axml b/BMM.UI.Android/Resources/layout/view_swipe_menu.axml new file mode 100644 index 000000000..35e5821e7 --- /dev/null +++ b/BMM.UI.Android/Resources/layout/view_swipe_menu.axml @@ -0,0 +1,25 @@ + + + + + + + \ No newline at end of file diff --git a/BMM.UI.Android/Resources/layout/view_swipe_menu_simple.xml b/BMM.UI.Android/Resources/layout/view_swipe_menu_simple.xml new file mode 100644 index 000000000..bddc8918f --- /dev/null +++ b/BMM.UI.Android/Resources/layout/view_swipe_menu_simple.xml @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/BMM.UI.iOS/Application/CustomViews/Swipes/Base/SwipeMenuBase.cs b/BMM.UI.iOS/Application/CustomViews/Swipes/Base/SwipeMenuBase.cs new file mode 100644 index 000000000..d02a96072 --- /dev/null +++ b/BMM.UI.iOS/Application/CustomViews/Swipes/Base/SwipeMenuBase.cs @@ -0,0 +1,133 @@ +using System.Drawing; +using BMM.Core.Extensions; +using BMM.UI.iOS.Constants; +using BMM.UI.iOS.TableViewCell.Base; +using MvvmCross.Commands; +using MvvmCross.Platforms.Ios.Binding.Views; +using MvvmCross.WeakSubscription; + +namespace BMM.UI.iOS.CustomViews.Swipes.Base +{ + public abstract class SwipeMenuBase : MvxView, IUIGestureRecognizerDelegate + { + private MvxCanExecuteChangedEventSubscription _canExecuteClickCommandSubscription; + private MvxCanExecuteChangedEventSubscription _canExecuteFullSwipeCommandSubscription; + private IMvxCommand _clickCommand; + private IMvxCommand _fullSwipeCommand; + private bool _initialized; + private UITapGestureRecognizer _tapGestureRecognizer; + private UILongPressGestureRecognizer _longPressGestureRecognizer; + + protected SwipeMenuBase(RectangleF bounds) : base(bounds) + { + } + + protected SwipeMenuBase(IntPtr handle) : base(handle) + { + } + + protected SwipeMenuBase(CGRect handle) : base(handle) + { + } + + protected SwipeMenuBase() + { + } + + public abstract UILabel LabelTitle { get; } + public bool Available { get; set; } = true; + public bool TreatAsSingleAction { set; get; } + public bool CanExecute => _clickCommand?.CanExecute(DataContext) ?? false; + + public bool CanExecuteFullSwipe => _fullSwipeCommand?.CanExecute(DataContext) + ?? _clickCommand?.CanExecute(DataContext) + ?? false; + + public IMvxCommand ClickCommand + { + get => _clickCommand; + set + { + _canExecuteClickCommandSubscription?.Dispose(); + _canExecuteClickCommandSubscription = null; + _clickCommand = value; + } + } + + public IMvxCommand FullSwipeCommand + { + get => _fullSwipeCommand; + set + { + _canExecuteFullSwipeCommandSubscription?.Dispose(); + _canExecuteFullSwipeCommandSubscription = null; + _fullSwipeCommand = value; + } + } + + public override void WillMoveToSuperview(UIView newsuper) + { + base.WillMoveToSuperview(newsuper); + + if(_initialized) + return; + + _tapGestureRecognizer = new UITapGestureRecognizer + { + Delegate = this + }; + + _tapGestureRecognizer.AddTarget(ClickAction); + AddGestureRecognizer(_tapGestureRecognizer); + + _initialized = true; + SetThemes(); + } + + private void SetThemes() + { + LabelTitle.ApplyTextTheme(AppTheme.Subtitle2Label1); + LabelTitle.TextColor = AppColors.GlobalWhiteOneColor; + } + + protected override void Dispose(bool isDisposing) + { + if (isDisposing) + { + _canExecuteClickCommandSubscription?.Dispose(); + _canExecuteClickCommandSubscription = null; + _canExecuteFullSwipeCommandSubscription?.Dispose(); + _canExecuteFullSwipeCommandSubscription = null; + _tapGestureRecognizer?.Dispose(); + _tapGestureRecognizer = null; + _longPressGestureRecognizer?.Dispose(); + _longPressGestureRecognizer = null; + } + + base.Dispose(isDisposing); + } + + private void ClickAction() + { + if (!CanExecute) + return; + + ClickCommand.Execute(DataContext); + HideMenu(); + } + + private void HideMenu() + { + if (Superview is UISwipeMenu swipeMenu) + swipeMenu.RequestHideMenus(); + } + + [Export("gestureRecognizer:shouldRecognizeSimultaneouslyWithGestureRecognizer:")] + public bool ShouldRecognizeSimultaneously( + UIGestureRecognizer gestureRecognizer, + UIGestureRecognizer otherGestureRecognizer) + { + return true; + } + } +} \ No newline at end of file diff --git a/BMM.UI.iOS/Application/CustomViews/Swipes/SwipeMenuSimpleItem.cs b/BMM.UI.iOS/Application/CustomViews/Swipes/SwipeMenuSimpleItem.cs new file mode 100644 index 000000000..9c5145c47 --- /dev/null +++ b/BMM.UI.iOS/Application/CustomViews/Swipes/SwipeMenuSimpleItem.cs @@ -0,0 +1,21 @@ +using BMM.UI.iOS.CustomViews.Swipes.Base; +using BMM.UI.iOS.Utils; + +namespace BMM.UI.iOS.CustomViews.Swipes +{ + public partial class SwipeMenuSimpleItem : SwipeMenuBase + { + public SwipeMenuSimpleItem() + { + XibLoad(); + } + + private void XibLoad() + { + var view = NibLoader.Load(nameof(SwipeMenuSimpleItem), Bounds, this); + AddSubview(view); + } + + public override UILabel LabelTitle => SwipeLabel; + } +} \ No newline at end of file diff --git a/BMM.UI.iOS/Application/CustomViews/Swipes/SwipeMenuSimpleItem.designer.cs b/BMM.UI.iOS/Application/CustomViews/Swipes/SwipeMenuSimpleItem.designer.cs new file mode 100644 index 000000000..63c09dcb2 --- /dev/null +++ b/BMM.UI.iOS/Application/CustomViews/Swipes/SwipeMenuSimpleItem.designer.cs @@ -0,0 +1,65 @@ +// WARNING +// +// This file has been generated automatically by Rider IDE +// to store outlets and actions made in Xcode. +// If it is removed, they will be lost. +// Manual changes to this file may not be handled correctly. +// +namespace BMM.UI.iOS.CustomViews.Swipes +{ + [Register ("SwipeMenuSimpleItem")] + partial class SwipeMenuSimpleItem + { + [Outlet] + UIKit.UIView BackgroundView { get; set; } + + [Outlet] + UIKit.NSLayoutConstraint SeparatorHeightConstraint { get; set; } + + [Outlet] + UIKit.NSLayoutConstraint SeparatorLeadingConstraint { get; set; } + + [Outlet] + UIKit.UIView SeparatorView { get; set; } + + [Outlet] + UIKit.UIImageView SwipeIcon { get; set; } + + [Outlet] + UILabel SwipeLabel { get; set; } + + void ReleaseDesignerOutlets () + { + if (BackgroundView != null) { + BackgroundView.Dispose (); + BackgroundView = null; + } + + if (SeparatorLeadingConstraint != null) { + SeparatorLeadingConstraint.Dispose (); + SeparatorLeadingConstraint = null; + } + + if (SeparatorView != null) { + SeparatorView.Dispose (); + SeparatorView = null; + } + + if (SwipeIcon != null) { + SwipeIcon.Dispose (); + SwipeIcon = null; + } + + if (SwipeLabel != null) { + SwipeLabel.Dispose (); + SwipeLabel = null; + } + + if (SeparatorHeightConstraint != null) { + SeparatorHeightConstraint.Dispose (); + SeparatorHeightConstraint = null; + } + + } + } +} diff --git a/BMM.UI.iOS/Application/CustomViews/Swipes/SwipeMenuSimpleItem.xib b/BMM.UI.iOS/Application/CustomViews/Swipes/SwipeMenuSimpleItem.xib new file mode 100644 index 000000000..641350bd5 --- /dev/null +++ b/BMM.UI.iOS/Application/CustomViews/Swipes/SwipeMenuSimpleItem.xib @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/BMM.UI.iOS/Application/Enums/PanDirection.cs b/BMM.UI.iOS/Application/Enums/PanDirection.cs new file mode 100644 index 000000000..20638350d --- /dev/null +++ b/BMM.UI.iOS/Application/Enums/PanDirection.cs @@ -0,0 +1,8 @@ +namespace BMM.UI.iOS.Enums +{ + public enum PanDirection + { + Horizontal, + Vertical + } +} \ No newline at end of file diff --git a/BMM.UI.iOS/Application/GestureRecognizers/PanDirectionGestureRecognizer.cs b/BMM.UI.iOS/Application/GestureRecognizers/PanDirectionGestureRecognizer.cs new file mode 100644 index 000000000..af7f2f42c --- /dev/null +++ b/BMM.UI.iOS/Application/GestureRecognizers/PanDirectionGestureRecognizer.cs @@ -0,0 +1,32 @@ +using BMM.UI.iOS.Enums; + +namespace BMM.UI.iOS.GestureRecognizers +{ + public class PanDirectionGestureRecognizer : UIPanGestureRecognizer + { + private readonly PanDirection _direction; + + public PanDirectionGestureRecognizer(PanDirection direction) + { + _direction = direction; + } + + public override void TouchesMoved(NSSet touches, UIEvent evt) + { + base.TouchesMoved(touches, evt); + + if (State == UIGestureRecognizerState.Began) + { + var velocity = VelocityInView(View); + switch (_direction) { + case PanDirection.Horizontal when Math.Abs(velocity.Y) > Math.Abs(velocity.X): + State = UIGestureRecognizerState.Cancelled; + break; + case PanDirection.Vertical when Math.Abs(velocity.X) > Math.Abs(velocity.Y): + State = UIGestureRecognizerState.Cancelled; + break; + } + } + } + } +} \ No newline at end of file diff --git a/BMM.UI.iOS/Application/NewMediaPlayer/IosMediaPlayer.cs b/BMM.UI.iOS/Application/NewMediaPlayer/IosMediaPlayer.cs index f8ee9dee8..3549027fb 100644 --- a/BMM.UI.iOS/Application/NewMediaPlayer/IosMediaPlayer.cs +++ b/BMM.UI.iOS/Application/NewMediaPlayer/IosMediaPlayer.cs @@ -252,7 +252,14 @@ public void PlayPreviousOrSeekToStart() SeekTo(0); } } - + + public async Task DeleteFromQueue(IMediaTrack track) + { + await Task.CompletedTask; + _queue.Delete(track); + SetCurrentTrackIndex(); + } + public Task AddToEndOfQueue(IMediaTrack track, string playbackOrigin, bool ignoreIfAlreadyAdded = false) { return _queue.Append(track); @@ -272,12 +279,17 @@ public void SetRepeatType(RepeatType type) public void SetShuffle(bool isShuffleEnabled) { _queue.SetShuffle(isShuffleEnabled, _currentTrack); - _currentTrackIndex = _queue.Tracks.IndexOf(_currentTrack); + SetCurrentTrackIndex(); _messenger.Publish(new ShuffleModeChangedMessage(this) {IsShuffleEnabled = isShuffleEnabled}); PlaybackStateChanged(); } + private void SetCurrentTrackIndex() + { + _currentTrackIndex = _queue.Tracks.IndexOf(_currentTrack); + } + public void SeekTo(long newPosition) { _messenger.Publish(new PlaybackSeekedMessage(this) diff --git a/BMM.UI.iOS/Application/TableViewCell/Base/SwipeableViewCell.cs b/BMM.UI.iOS/Application/TableViewCell/Base/SwipeableViewCell.cs new file mode 100644 index 000000000..11a981a1c --- /dev/null +++ b/BMM.UI.iOS/Application/TableViewCell/Base/SwipeableViewCell.cs @@ -0,0 +1,317 @@ +using BMM.Core.Extensions; +using BMM.Core.Models.Enums; +using BMM.UI.iOS.Enums; +using BMM.UI.iOS.GestureRecognizers; +using BMM.UI.iOS.TableViewSource.Base; +using MvvmCross.Commands; +using static BMM.Core.Constants.ViewConstants; + +namespace BMM.UI.iOS.TableViewCell.Base +{ + public abstract class SwipeableViewCell : BaseBMMTableViewCell + { + public UISwipeMenu LeftMenu { get; private set; } + public UISwipeMenu RightMenu { get; private set; } + private NSLayoutConstraint _leftConstraint; + private NSLayoutConstraint _rightConstraint; + private nfloat _leftConstant; + private nfloat _rightConstant; + private UIView _mainView; + private readonly UIPanGestureRecognizer _panGestureRecognizer; + private float _startingTouchOffsetLeft; + private float _startingTouchOffsetRight; + private bool _initialized; + private ISwipeableTableViewSource _swipeableSource; + + protected SwipeableViewCell(IntPtr handle) : base(handle) + { + LeftMenu = new UISwipeMenu(SwipePlacement.Left, this); + RightMenu = new UISwipeMenu(SwipePlacement.Right, this); + + _panGestureRecognizer = new PanDirectionGestureRecognizer(PanDirection.Horizontal); + _panGestureRecognizer.AddTarget(HandleMove); + } + + public bool Enabled { get => _panGestureRecognizer.Enabled; set => _panGestureRecognizer.Enabled = value; } + public bool IsBeingTouched { get; set; } + public IMvxCommand SwipeStartedCommand { get; set; } + + public void SetRecognizerDelegate(UIGestureRecognizerDelegate gestureDelegate) + { + _panGestureRecognizer.Delegate = gestureDelegate; + } + + public void SetSwipeableSource(ISwipeableTableViewSource swipeableSource) + { + _swipeableSource = swipeableSource; + } + + public abstract void SetupAndBindMenus(); + + protected void AttachToCenterView() + { + ContentView.Add(LeftMenu); + ContentView.Add(RightMenu); + ContentView.AddGestureRecognizer(_panGestureRecognizer); + + _mainView = ContentView.Subviews.FirstOrDefault(); + if (_mainView != null) + { + _leftConstraint = + ContentView.Constraints.First(x => x.FirstAttribute == NSLayoutAttribute.Leading && x.Active); + _leftConstant = _leftConstraint.Constant; + _rightConstraint = + ContentView.Constraints.First(x => x.FirstAttribute == NSLayoutAttribute.Trailing && x.Active); + _rightConstant = _rightConstraint.Constant; + } + } + + private void HandleMove() + { + if (_swipeableSource?.CellWithSwipeInProgress != null && + _swipeableSource?.CellWithSwipeInProgress != this) + { + return; + } + + var offset = _panGestureRecognizer.TranslationInView(this); + + switch (_panGestureRecognizer.State) + { + case UIGestureRecognizerState.Began: + HandleTouchBegan(); + break; + case UIGestureRecognizerState.Changed: + UpdateSwipeFromTouch(offset.X); + break; + case UIGestureRecognizerState.Ended: + case UIGestureRecognizerState.Cancelled: + case UIGestureRecognizerState.Failed: + HandleTouchEnded(offset.X) + .FireAndForget(); + break; + } + } + + public async Task PresentSwipe() + { + var taskSource = new TaskCompletionSource(); + InvokeOnMainThread(async () => await PresentSwipe(taskSource)); + await taskSource.Task; + + async Task PresentSwipe(TaskCompletionSource taskCompletionSource) + { + HandleTouchBegan(); + float x = RightMenu.GetPointToSnap(RightMenu.ItemWidth); + await HandleTouchEnded(x); + await Task.Delay(TimeSpan.FromSeconds(LongAnimationDuration)); + + AnimateResetSwipe( + async () => + { + await Task.Delay(TimeSpan.FromSeconds(LongAnimationDuration)); + HandleTouchBegan(); + x = LeftMenu.GetPointToSnap(LeftMenu.ItemWidth); + await HandleTouchEnded(x); + taskCompletionSource.TrySetResult(true); + }); + } + } + + private async Task HandleTouchEnded(nfloat x) + { + if (!IsBeingTouched) + return; + + await SnapToNearestView(x); + + if (Superview is UITableView tableView) + { + tableView.ScrollEnabled = true; + } + + IsBeingTouched = false; + + if (_swipeableSource != null) + _swipeableSource.CellWithSwipeInProgress = null; + } + + private void HandleTouchBegan() + { + if (_swipeableSource != null) + { + _swipeableSource.CellWithSwipeInProgress = this; + } + InitializeIfNeeded(); + SwipeStartedCommand?.Execute(); + + if (Superview is UITableView tableView) + tableView.ScrollEnabled = false; + + IsBeingTouched = true; + _swipeableSource?.ResetVisibleCells(true); + RefreshMenu(LeftMenu); + RefreshMenu(RightMenu); + + _startingTouchOffsetLeft = (float)(_leftConstraint.Constant - _leftConstant); + _startingTouchOffsetRight = (float)(_rightConstraint.Constant - _rightConstant); + } + + private void RefreshMenu(UISwipeMenu menu) + { + menu.UpdateAvailability(); + } + + private void InitializeIfNeeded() + { + if (!_initialized) + { + SetupAndBindMenus(); + LayoutIfNeeded(); + _initialized = true; + } + } + + public Task AnimateToOffset( + nfloat offsetX, + float duration = DefaultAnimationDuration, + Func onAnimationEnd = null) + { + var taskSource = new TaskCompletionSource(); + + LayoutIfNeeded(); + Animate( + duration, + () => + { + UpdateSwipe(offsetX); + }, + async () => + { + if (onAnimationEnd != null) + await onAnimationEnd.Invoke(); + taskSource.TrySetResult(true); + }); + + return taskSource.Task; + } + + private async Task SnapToNearestView(nfloat offsetX) + { + var delta = _startingTouchOffsetLeft + offsetX; + if (delta > 0) + { + //left menu + float point = LeftMenu.GetPointToSnap(delta); + + if (point > Frame.Width) + { + await AnimateToOffset( + point, + DefaultAnimationDuration, + () => ExecuteCommandAndReset(LeftMenu)); + } + else + { + await AnimateToOffset(point); + } + } + else + { + //right menu + float point = RightMenu.GetPointToSnap(delta); + + if (point < -Frame.Width) + { + await AnimateToOffset( + point, + DefaultAnimationDuration, + () => ExecuteCommandAndReset(RightMenu)); + } + else + { + await AnimateToOffset(point); + } + } + } + + private async Task ExecuteCommandAndReset(UISwipeMenu menu) + { + await menu.ExecuteFullSwipe(DataContext); + Animate( + DefaultAnimationDuration, + 0.2d, + UIViewAnimationOptions.CurveEaseInOut, + Reset, + null); + } + + public void Refresh() + { + RefreshMenu(LeftMenu); + RefreshMenu(RightMenu); + } + + public void ResetSwipe() + { + if (IsResetNeeded()) + { + Reset(); + } + } + + private void Reset() + { + _startingTouchOffsetLeft = 0; + _startingTouchOffsetRight = 0; + UpdateSwipe(0); + } + + public void AnimateResetSwipe(Action onCompletion = null) + { + if (IsResetNeeded()) + { + LayoutIfNeeded(); + Animate( + DefaultAnimationDuration, + Reset, + () => onCompletion?.Invoke()); + } + } + + private bool IsResetNeeded() + { + return _initialized + && !IsBeingTouched + && (_rightConstraint.Constant != _rightConstant || _rightConstraint.Constant != _rightConstant); + } + + private void UpdateSwipe(nfloat delta) + { + _leftConstraint.Constant = _leftConstant + delta; + _rightConstraint.Constant = _rightConstant - delta; + Superview?.LayoutIfNeeded(); + } + + private void UpdateSwipeFromTouch(nfloat delta) + { + if (!IsBeingTouched) + return; + + var leftOffset = _startingTouchOffsetLeft + delta; + var rightOffset = _startingTouchOffsetRight - delta; + + if (LeftMenu.MaxSwipePoint < leftOffset || RightMenu.MaxSwipePoint < rightOffset) + return; + + _leftConstraint.Constant = leftOffset + _leftConstant; + _rightConstraint.Constant = rightOffset + _rightConstant; + } + + public override void AwakeFromNib() + { + base.AwakeFromNib(); + AttachToCenterView(); + } + } +} \ No newline at end of file diff --git a/BMM.UI.iOS/Application/TableViewCell/Base/UISwipeMenu.cs b/BMM.UI.iOS/Application/TableViewCell/Base/UISwipeMenu.cs new file mode 100644 index 000000000..892f11541 --- /dev/null +++ b/BMM.UI.iOS/Application/TableViewCell/Base/UISwipeMenu.cs @@ -0,0 +1,355 @@ +using BMM.Core.Models.Enums; +using BMM.Core.Models.POs.Base.Interfaces; +using BMM.UI.iOS.Constants; +using BMM.UI.iOS.CustomViews.Swipes.Base; +using BMM.UI.iOS.Enums; +using BMM.UI.iOS.TableViewCell.Base; +using MvvmCross.Commands; + +namespace BMM.UI.iOS.TableViewCell.Base +{ + public class UISwipeMenu : UIView + { + private readonly SwipeableViewCell _swipeableViewCell; + private NSLayoutConstraint _smallViewConstraint; + private NSLayoutConstraint _expandedViewConstraint; + private NSLayoutConstraint _expandedWidthViewConstraint; + private NSLayoutConstraint _heightConstraint; + private const double EdgePercent = 0.7; + + private readonly IList _items = new List(); + + public UISwipeMenu(SwipePlacement placement, SwipeableViewCell swipeableViewCell, float itemWidth = 60) + { + _swipeableViewCell = swipeableViewCell; + ItemWidth = itemWidth; + Placement = placement; + TranslatesAutoresizingMaskIntoConstraints = false; + ClipsToBounds = true; + } + + public float MaxSwipePoint => (float)(FullSwipeAvailable + ? Superview?.Frame.Width ?? 0 + (Subviews.Length - 1) * ItemWidth + : Subviews.Length * ItemWidth); + + public float ItemWidth { get; set; } + public SwipePlacement Placement { get; private set; } + public bool AllowFullSwipe { get; set; } = true; + + public bool FullSwipeAvailable + => AllowFullSwipe && (_items.FirstOrDefault(x => x.Available)?.CanExecuteFullSwipe ?? false); + + public async Task ExecuteFullSwipe(object dataContext) + { + if (!(_items.FirstOrDefault(x => x.Available) is { } firstItem)) + return; + + var command = firstItem.FullSwipeCommand ?? firstItem.ClickCommand; + if (command?.CanExecute(dataContext) ?? false) + { + // TODO: Refactor all commands used in swipes to async with one base interface + switch (command) + { + case IMvxAsyncCommand asyncCommand: + await asyncCommand.ExecuteAsync(dataContext as IBasePO); + break; + case IMvxAsyncCommand asyncCommandWithParam when dataContext is IBasePO basePO: + await asyncCommandWithParam.ExecuteAsync(basePO); + break; + default: + command.Execute(dataContext); + break; + } + } + } + + public void RequestHideMenus() => _swipeableViewCell.AnimateResetSwipe(); + + public override void LayoutSubviews() + { + if (FullSwipeAvailable && Frame.Width > _items.Count(x => x.Available) * ItemWidth) + { + if (_smallViewConstraint != null) + _smallViewConstraint.Active = false; + + if (_expandedViewConstraint != null) + _expandedViewConstraint.Active = true; + + if (_expandedWidthViewConstraint != null) + _expandedWidthViewConstraint.Active = true; + } + else + { + if (_smallViewConstraint != null) + _smallViewConstraint.Active = true; + + if (_expandedViewConstraint != null) + _expandedViewConstraint.Active = false; + + if (_expandedWidthViewConstraint != null) + _expandedWidthViewConstraint.Active = false; + } + + base.LayoutSubviews(); + } + + public void UpdateAvailability() + { + var items = _items.ToArray(); + _items.Clear(); + + foreach (var menuItem in items) + menuItem.RemoveFromSuperview(); + + foreach (var menuItem in items) + { + if (menuItem.Available) + AddItem(menuItem); + } + + _heightConstraint.Constant = Superview.Frame.Height; + } + + public override void MovedToSuperview() + { + base.MovedToSuperview(); + + NSLayoutConstraint.Create( + this, + NSLayoutAttribute.CenterY, + NSLayoutRelation.Equal, + Superview, + NSLayoutAttribute.CenterY, + 1, + 0).Active = true; + + _heightConstraint = NSLayoutConstraint.Create( + this, + NSLayoutAttribute.Height, + NSLayoutRelation.Equal, + 1, + Superview.Frame.Height); + _heightConstraint.Active = true; + + var contentView = Superview.Subviews.First(); + + if (Placement == SwipePlacement.Left) + { + var leadingToSuperviewLeadingConstraint = NSLayoutConstraint.Create( + this, + NSLayoutAttribute.Leading, + NSLayoutRelation.Equal, + Superview, + NSLayoutAttribute.Leading, + 1, + 0); + + leadingToSuperviewLeadingConstraint.Priority = ConstraintsConstants.VeryHighPriority; + leadingToSuperviewLeadingConstraint.Active = true; + + var leadingConstraint = + Superview.Constraints.FirstOrDefault(x => x.FirstAttribute == NSLayoutAttribute.Leading); + + var trailingToContentViewLeadingConstraint = NSLayoutConstraint.Create( + this, + NSLayoutAttribute.Trailing, + NSLayoutRelation.Equal, + contentView, + NSLayoutAttribute.Leading, + 1, + leadingConstraint != null + ? -leadingConstraint.Constant + : 0); + + trailingToContentViewLeadingConstraint.Priority = ConstraintsConstants.VeryHighPriority; + trailingToContentViewLeadingConstraint.Active = true; + } + else + { + var trailingToSuperviewTrailingConstraint = NSLayoutConstraint.Create( + this, + NSLayoutAttribute.Trailing, + NSLayoutRelation.Equal, + Superview, + NSLayoutAttribute.Trailing, + 1, + 0); + + trailingToSuperviewTrailingConstraint.Priority = ConstraintsConstants.VeryHighPriority; + trailingToSuperviewTrailingConstraint.Active = true; + + var trailingConstraint = + Superview.Constraints.FirstOrDefault(x => x.FirstAttribute == NSLayoutAttribute.Trailing); + + var leadingToContentViewTrailingConstraints = NSLayoutConstraint.Create( + this, + NSLayoutAttribute.Leading, + NSLayoutRelation.Equal, + contentView, + NSLayoutAttribute.Trailing, + 1, + trailingConstraint?.Constant ?? 0); + + leadingToContentViewTrailingConstraints.Priority = ConstraintsConstants.VeryHighPriority; + leadingToContentViewTrailingConstraints.Active = true; + } + } + + public void AddItem(SwipeMenuBase subview) + { + subview.TranslatesAutoresizingMaskIntoConstraints = false; + var prevView = Subviews.LastOrDefault(); + _items.Add(subview); + Add(subview); + + CreateVerticalConstraints(subview); + + if (subview.TreatAsSingleAction) + { + CreateSideConstraint(subview); + CreateWidthConstraintsIfNeeded(subview); + CreateExpandedViewConstraintIfNeeded(subview); + return; + } + + if (prevView == null) + { + CreateSideConstraint(subview); + CreateWidthConstraintsIfNeeded(subview); + } + else + { + //next elements have ItemWidth as width + AddConstraint(NSLayoutConstraint.Create( + subview, + NSLayoutAttribute.Width, + NSLayoutRelation.Equal, + 1, + ItemWidth)); + + if (_expandedViewConstraint != null) + _expandedViewConstraint.Active = false; + + CreateExpandedViewConstraintIfNeeded(subview); + + var leftSideConstraint = Placement == SwipePlacement.Left + ? NSLayoutAttribute.Trailing + : NSLayoutAttribute.Leading; + var rightSideConstraint = Placement == SwipePlacement.Left + ? NSLayoutAttribute.Leading + : NSLayoutAttribute.Trailing; + + AddConstraint( + NSLayoutConstraint.Create( + subview, + rightSideConstraint, + NSLayoutRelation.Equal, + prevView, + leftSideConstraint, + 1, + 0)); + } + } + + private void CreateVerticalConstraints(SwipeMenuBase subview) + { + AddConstraint( + NSLayoutConstraint.Create( + subview, + NSLayoutAttribute.Top, + NSLayoutRelation.Equal, + this, + NSLayoutAttribute.Top, + 1, + 0)); + + AddConstraint( + NSLayoutConstraint.Create( + subview, + NSLayoutAttribute.Bottom, + NSLayoutRelation.Equal, + this, + NSLayoutAttribute.Bottom, + 1, + 0)); + } + + private void CreateSideConstraint(UIView subview) + { + var constraintSide = Placement == SwipePlacement.Left + ? NSLayoutAttribute.Leading + : NSLayoutAttribute.Trailing; + + AddConstraint( + NSLayoutConstraint.Create( + subview, + constraintSide, + NSLayoutRelation.Equal, + this, + constraintSide, + 1, + 0)); + } + + private void CreateWidthConstraintsIfNeeded(UIView subview) + { + if (_smallViewConstraint == null) + { + _smallViewConstraint = NSLayoutConstraint.Create( + subview, + NSLayoutAttribute.Width, + NSLayoutRelation.Equal, + 1, + ItemWidth); + _smallViewConstraint.Active = true; + } + + if (_expandedWidthViewConstraint == null) + { + _expandedWidthViewConstraint = NSLayoutConstraint.Create( + subview, + NSLayoutAttribute.Width, + NSLayoutRelation.GreaterThanOrEqual, + 1, + ItemWidth); + } + } + + private void CreateExpandedViewConstraintIfNeeded(SwipeMenuBase subview) + { + var leftSideConstraint = Placement == SwipePlacement.Left + ? NSLayoutAttribute.Trailing + : NSLayoutAttribute.Leading; + + _expandedViewConstraint = NSLayoutConstraint.Create( + subview, + leftSideConstraint, + NSLayoutRelation.Equal, + this, + leftSideConstraint, + 1, + 0); + } + + public float GetPointToSnap(nfloat offsetX) + { + var width = (float)Superview.Frame.Width; + + offsetX = offsetX > 0 ? offsetX : -offsetX; + + if (offsetX < ItemWidth) + return 0; + + float animationPoint = 0; + + if (offsetX < width * EdgePercent || !FullSwipeAvailable) + animationPoint = Subviews.Length * ItemWidth; + else + animationPoint = width + (Subviews.Length - 1) * ItemWidth + 1; + + return Placement == SwipePlacement.Left + ? animationPoint + : -animationPoint; + } + } +} \ No newline at end of file diff --git a/BMM.UI.iOS/Application/TableViewCell/TrackTableViewCell.cs b/BMM.UI.iOS/Application/TableViewCell/TrackTableViewCell.cs index 09c86168b..6faec6f19 100644 --- a/BMM.UI.iOS/Application/TableViewCell/TrackTableViewCell.cs +++ b/BMM.UI.iOS/Application/TableViewCell/TrackTableViewCell.cs @@ -1,15 +1,19 @@ using BMM.Core.Constants; +using BMM.Core.Helpers; using MvvmCross.Binding.BindingContext; using BMM.Core.Models.POs.Tracks; using BMM.Core.ValueConverters; using BMM.UI.iOS.Constants; +using BMM.UI.iOS.CustomViews.Swipes; using BMM.UI.iOS.Extensions; +using BMM.UI.iOS.TableViewCell.Base; using CoreAnimation; using MvvmCross.Platforms.Ios.Binding; +using BMM.Core.Translation; namespace BMM.UI.iOS { - public partial class TrackTableViewCell : BaseBMMTableViewCell + public partial class TrackTableViewCell : SwipeableViewCell { public static readonly NSString Key = new(nameof(TrackTableViewCell)); private TrackState _trackState; @@ -108,5 +112,37 @@ private void SetThemes() { metaLabel.ApplyTextTheme(AppTheme.Subtitle3Label3); } + + public override void SetupAndBindMenus() + { + var set = this.CreateBindingSet(); + + RightMenu.AddItem(CreateItem(set)); + LeftMenu.AddItem(CreateItem(set)); + + set.Apply(); + } + + private static SwipeMenuSimpleItem CreateItem( + MvxFluentBindingDescriptionSet set) + { + var item = new SwipeMenuSimpleItem + { + TreatAsSingleAction = true + }; + + set.Bind(item.LabelTitle) + .To(po => po.TextSource[Translations.QueueViewModel_Delete]); + + set.Bind(item) + .For(i => i.ClickCommand) + .To(po => po.DeleteFromQueueCommand); + + set.Bind(item) + .For(i => i.FullSwipeCommand) + .To(po => po.DeleteFromQueueCommand); + + return item; + } } } \ No newline at end of file diff --git a/BMM.UI.iOS/Application/TableViewCell/TrackTableViewCell.xib b/BMM.UI.iOS/Application/TableViewCell/TrackTableViewCell.xib index aec80a929..237fcfd1c 100644 --- a/BMM.UI.iOS/Application/TableViewCell/TrackTableViewCell.xib +++ b/BMM.UI.iOS/Application/TableViewCell/TrackTableViewCell.xib @@ -1,8 +1,8 @@ - + - + @@ -12,104 +12,116 @@ - + - - - - - - - - - - - + + - - - + + - + + - - + + + + - - + - - - - - - - - - - - - - - + + + + diff --git a/BMM.UI.iOS/Application/TableViewSource/Base/ISwipeableTableViewSource.cs b/BMM.UI.iOS/Application/TableViewSource/Base/ISwipeableTableViewSource.cs new file mode 100644 index 000000000..867806c24 --- /dev/null +++ b/BMM.UI.iOS/Application/TableViewSource/Base/ISwipeableTableViewSource.cs @@ -0,0 +1,8 @@ +namespace BMM.UI.iOS.TableViewSource.Base +{ + public interface ISwipeableTableViewSource + { + void ResetVisibleCells(bool animate = false); + UITableViewCell CellWithSwipeInProgress { get; set; } + } +} \ No newline at end of file diff --git a/BMM.UI.iOS/Constants/ConstraintsConstants.cs b/BMM.UI.iOS/Constants/ConstraintsConstants.cs new file mode 100644 index 000000000..295056afa --- /dev/null +++ b/BMM.UI.iOS/Constants/ConstraintsConstants.cs @@ -0,0 +1,9 @@ +namespace BMM.UI.iOS.Constants; + +public class ConstraintsConstants +{ + public const int LowPriority = 250; + public const int HighPriority = 750; + public const int VeryHighPriority = 999; + public const int RegularPriority = 1000; +} \ No newline at end of file