Expander with CollectionView does not work properly with iOS

622 Views Asked by At

In my .Net MAUI app with CommunityToolkit.mvvm and CommunityToolkit.maui, I have an expander with a CollectionView in it:

                <toolkit:Expander Margin="0,25">
                    <toolkit:Expander.Header>
                        <StackLayout Orientation="Horizontal">
                            <Label Text="{Binding LabelSelectTimeText}" FontFamily = "FontAwesome" Margin="0,0,25,0" VerticalTextAlignment="Center" />
                            <Label Text="{Binding SelectedTimeDisplay, Mode=TwoWay, StringFormat='{0:hh:mm tt}'}" FontSize="Medium" VerticalTextAlignment="Center" />
                        </StackLayout>
                    </toolkit:Expander.Header>
                    <CollectionView 
                        SelectionChanged="CollectionView_SelectionChanged"
                        Margin="5,5,5,15"
                        ItemSizingStrategy="MeasureAllItems"
                        ItemsSource="{Binding Times}"
                        SelectedItem="{Binding SelectedTime, Mode=TwoWay}"
                        SelectionMode="Single">
                        <CollectionView.ItemsLayout>
                            <GridItemsLayout Orientation="Vertical" Span="3" />
                        </CollectionView.ItemsLayout>
                        <CollectionView.ItemTemplate>
                            <DataTemplate>
                                <ContentView Padding="5" IsEnabled="{Binding IsAvailable}" >
                                    <Border
                                        StrokeThickness="1"
                                        StrokeShape="RoundRectangle 10">
                                        <Label 
                                            Text="{Binding Time, StringFormat='{0:hh:mm tt}'}"
                                            Padding="10"
                                            FontAttributes="Bold"
                                            HorizontalTextAlignment="Center"
                                            BackgroundColor="{Binding IsAvailable, Converter={StaticResource BooleanToTimeColorConverter}}"
                                            TextColor="Black" />
                                    </Border>
                                </ContentView>
                            </DataTemplate>
                        </CollectionView.ItemTemplate>
                    </CollectionView>
                </toolkit:Expander>

On Android it works as expected, but on iOS the contents shows up when expanded first time, and after that no more. When the expander is collapsed, and then expanded next time, it is empty. How can this be fixed?

P.S. When I replaced the CollectionView with a simple label, the expander works properly.

When expanded, it should look like this:

enter image description here

P.P.S. Here is my viewmodel:


using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LATICRETE_MobileApp.Common;
using LATICRETE_MobileApp.Resources;
using Microsoft.Graph.Models;
using Newtonsoft.Json;
using Plugin.LocalNotification;
using System.ComponentModel.DataAnnotations;
using System.Net.Http.Headers;
using System.Text;

namespace LATICRETE_MobileApp.Features.VideoChat { public partial class AppointmentSchedulePageModel : BasePageModel, IQueryAttributable { private const string ServiceId = "1234568f-51ec-4b00-8704-5c32fc70cf13"; private const string BusinessId = "[email protected]"; private const string ApptsGraphUrl = $"https://graph.microsoft.com/v1.0/solutions/bookingBusinesses/{BusinessId}/appointments"; private const string CustomersGraphUrl = $"https://graph.microsoft.com/v1.0/solutions/bookingBusinesses/{BusinessId}/customers"; private const string StaffGraphUrl = $"https://graph.microsoft.com/v1.0/solutions/bookingBusinesses/{BusinessId}/staffMembers"; private const string StaffAvilabilityGraphUrl = $"https://graph.microsoft.com/v1.0/solutions/bookingBusinesses/{BusinessId}/getStaffAvailability"; private const string OutlookScheduleGraphUrl = "https://graph.microsoft.com/v1.0/users/{0}/calendar/getSchedule";

    private const string DateTimeFormat = "yyyy-MM-ddTHH:mm:ss.0000000+00:00";

    public string ButtonScheduleAppointmentText { get; set; } = $"Schedule a Call {IconFont.Calendar}";
    public string LabelSelectTimeText { get; set; } = $"TIME {IconFont.ChevronDown}";

    [ObservableProperty]
    private BookingAppointment _appointment;

    [ObservableProperty]
    private BookingCustomer _customer;

    [ObservableProperty]
    [Required(ErrorMessage = "Please select the date of your appointment.")]
    private DateTime? _appointmentDate = DateTime.Now.Date;

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(SelectedTimeDisplay))]
    [Required(ErrorMessage = "Please select the time of your appointment.")]
    private AppointmentTime _selectedTime;

    public DateTime? SelectedTimeDisplay => SelectedTime?.Time;

    [ObservableProperty]
    [Phone(ErrorMessage = "Not valid phone number")]
    private string _customerPhone;

    [ObservableProperty]
    private string _notes;

    private HttpClient _httpClient;

    private string _msGraphAccessToken;

    private List<BookingStaffMember> _staffMembers;

    [ObservableProperty]
    private List<AppointmentTime> _times = new()
    { new AppointmentTime(2000, 1, 1, 9, 0, 0), new AppointmentTime(2000, 1, 1, 9, 30, 0), new AppointmentTime(2000, 1, 1, 10, 0, 0), new AppointmentTime(2000, 1, 1, 10, 30, 0),
      new AppointmentTime(2000, 1, 1, 11, 0, 0), new AppointmentTime(2000, 1, 1, 11, 30, 0), new AppointmentTime(2000, 1, 1, 12, 0, 0), new AppointmentTime(2000, 1, 1, 12, 30, 0),
      new AppointmentTime(2000, 1, 1, 13, 0, 0), new AppointmentTime(2000, 1, 1, 13, 30, 0), new AppointmentTime(2000, 1, 1, 14, 0, 0), new AppointmentTime(2000, 1, 1, 14, 30, 0),
      new AppointmentTime(2000, 1, 1, 15, 0, 0), new AppointmentTime(2000, 1, 1, 15, 30, 0), new AppointmentTime(2000, 1, 1, 16, 0, 0), new AppointmentTime(2000, 1, 1, 16, 30, 0) };

    public void ApplyQueryAttributes(IDictionary<string, object> query)
    {
        _httpClient = (HttpClient)query["HttpClient"];
        _msGraphAccessToken = query["MsGraphAccessToken"]?.ToString();
    }

    public async Task InitializeAsync()
    {
        // Retrieve staff members information
        // https://learn.microsoft.com/en-us/graph/api/resources/bookingstaffmember?view=graph-rest-1.0
        HttpRequestMessage request = new(HttpMethod.Get, StaffGraphUrl);
        request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _msGraphAccessToken);
        HttpResponseMessage response = await _httpClient.SendAsync(request).ConfigureAwait(false);
        string responseJson = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
        StaffRootObject staff = JsonConvert.DeserializeObject<StaffRootObject>(responseJson);
        _staffMembers = staff.Value;
        SelectedTime = null; // this is needed, as this method is also used to clear/refresh all settings

        await AppointmentDateChangedAsync((DateTime)AppointmentDate);

        IsInitialized = true;
    }

    [RelayCommand]
    private async Task AppointmentDateChangedAsync(DateTime selectedDate)
    {
        IsBusy = true;

        Times.ForEach(time =>
        {
            time.IsAvailable = false;
            time.FreeStaffMembers.Clear();
        });

        // Retrieve staff members availability for the selected date

        // https://learn.microsoft.com/en-us/graph/api/bookingbusiness-getstaffavailability?view=graph-rest-1.0&tabs=http
        DateTime startDateTime = new(selectedDate.Year, selectedDate.Month, selectedDate.Day, 9, 0, 0);
        DateTime endDateTime = new(selectedDate.Year, selectedDate.Month, selectedDate.Day, 22, 0, 0);
        string customerTimeZone = TimeZoneInfo.Local.StandardName;

#if IOS // Do this, as in iOS local time is returned in form of an abbreviation Dictionary<string, string> AbbreviationToFullNameMap = new() { { "EST", "Eastern Standard Time" }, { "EDT", "Eastern Daylight Time" }, { "CST", "Central Standard Time" }, { "CDT", "Central Daylight Time" }, { "MST", "Mountain Standard Time" }, { "MDT", "Mountain Daylight Time" }, { "PST", "Pacific Standard Time" }, { "PDT", "Pacific Daylight Time" }, // Add more mappings for other time zones as needed };

        customerTimeZone = AbbreviationToFullNameMap[customerTimeZone];

#endif

        GetStaffAvailabilityPostRequestBody staffAvailability = new()
        {
            //StaffIds = new List<string>
            //{
            //    "fb2fb357-e88c-4ccf-82e5-d45bb4957bb5",
            //},
            StaffIds = _staffMembers.Select(sm => sm.Id).ToList(),
            StartDateTime = new DateTimeTimeZone
            {
                DateTime = startDateTime.ToString(DateTimeFormat),
                TimeZone = customerTimeZone,
            },
            EndDateTime = new DateTimeTimeZone
            {
                DateTime = endDateTime.ToString(DateTimeFormat),
                TimeZone = customerTimeZone,
            },
        };

        HttpRequestMessage request = new(HttpMethod.Post, StaffAvilabilityGraphUrl);
        request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

        string jsonAvailability = JsonConvert.SerializeObject(staffAvailability);
        request.Content = new StringContent(jsonAvailability);
        request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");

        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _msGraphAccessToken);
        HttpResponseMessage response = await _httpClient.SendAsync(request).ConfigureAwait(false);

        string responseJson = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
        StaffAvailabilityRootObject availability = JsonConvert.DeserializeObject<StaffAvailabilityRootObject>(responseJson);

        // From MS Outlook, get schedule with all busy time slots

        GetSchedulePostRequestBody outlookScheduleRequestBody = new()
        {
            Schedules = _staffMembers.Select(sm => sm.EmailAddress).ToList(),
            StartTime = new DateTimeTimeZone
            {
                DateTime = startDateTime.ToString(DateTimeFormat),
                TimeZone = customerTimeZone,
            },
            EndTime = new DateTimeTimeZone
            {
                DateTime = endDateTime.ToString(DateTimeFormat),
                TimeZone = customerTimeZone,
            },
        };

        HttpRequestMessage outlookScheduleRequest = new(HttpMethod.Post, string.Format(OutlookScheduleGraphUrl, _staffMembers.FirstOrDefault().EmailAddress));
        outlookScheduleRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
        outlookScheduleRequest.Headers.Add("Prefer", $"outlook.timezone=\"{customerTimeZone}\"");

        string jsonOutlookScheduleRequestBody = JsonConvert.SerializeObject(outlookScheduleRequestBody);
        outlookScheduleRequest.Content = new StringContent(jsonOutlookScheduleRequestBody);
        outlookScheduleRequest.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");

        outlookScheduleRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _msGraphAccessToken);
        response = await _httpClient.SendAsync(outlookScheduleRequest).ConfigureAwait(false);

        responseJson = await response.Content.ReadAsStringAsync().ConfigureAwait(false);

        ScheduleInformationRootObject outlookSchedule = JsonConvert.DeserializeObject<ScheduleInformationRootObject>(responseJson);

        // Check availability for each time slot based on Staff Member Availability in Bookings Calendar (it shows only appointments scheduled through bookings, and the rest of the time is considered available)
        foreach (AppointmentTime time in Times)
        {
            DateTime appointmentDateTime = new
                (selectedDate.Year, selectedDate.Month, selectedDate.Day,
                time.Time.Hour, time.Time.Minute, time.Time.Second);

            // Check if the time is available for any of the staff members (represented by StaffAvailabilityItem)

            time.FreeStaffMembers = availability.Value
                .Where(sa => sa.AvailabilityItems.Any(ai =>
                    ai.Status == BookingsAvailabilityStatus.Available &&
                    appointmentDateTime > DateTime.Now &&
                    appointmentDateTime >= DateTime.Parse(ai.StartDateTime.DateTime) &&
                    appointmentDateTime <= DateTime.Parse(ai.EndDateTime.DateTime).AddSeconds(-1)))
                .Select(sa => _staffMembers.Single(sm => sm.Id == sa.StaffId)) // as StaffAvailabilityItem (sa) has only StaffId, we retrieve the whole BookingStaffMember from _staffMembers
                .ToList();

            if (time.FreeStaffMembers.Any())
            {
                time.IsAvailable = true;
            }
        }

        // Now check availability for each time slot based on Outlook calendar
        foreach (AppointmentTime time in Times.Where(time => time.IsAvailable))
        {
            DateTime appointmentDateTime = new
                (selectedDate.Year, selectedDate.Month, selectedDate.Day,
                time.Time.Hour, time.Time.Minute, time.Time.Second);

            // Check if the time is busy for all of the staff members (represented by ScheduleInformation)
            bool timeIsBusy = outlookSchedule.Value.All(scheduleInfo =>
            {
                return scheduleInfo.ScheduleItems.Any(si =>
                {
                    bool timeIsBusy = (si.Status == FreeBusyStatus.Busy || si.Status == FreeBusyStatus.Oof || si.Status == FreeBusyStatus.Tentative || si.Status == FreeBusyStatus.Unknown) &&
                        appointmentDateTime > DateTime.Now &&
                        appointmentDateTime >= DateTime.Parse(si.Start.DateTime) &&
                        appointmentDateTime <= DateTime.Parse(si.End.DateTime).AddSeconds(-1);

                    return timeIsBusy;
                });
            });

            if (timeIsBusy) // all staff members are busy at this time
            {
                time.IsAvailable = false;
                time.FreeStaffMembers.Clear();
            }
            else // find all free staff members emails for this time
            {
                time.FreeStaffMembers = outlookSchedule.Value
                    .Where(staffMember =>
                        !staffMember.ScheduleItems.Any(item =>
                            appointmentDateTime > DateTime.Now &&
                            appointmentDateTime >= DateTime.Parse(item.Start.DateTime) &&
                            appointmentDateTime <= DateTime.Parse(item.End.DateTime).AddSeconds(-1) &&
                            (item.Status == FreeBusyStatus.Busy ||
                            item.Status == FreeBusyStatus.Oof ||
                            item.Status == FreeBusyStatus.Tentative ||
                            item.Status == FreeBusyStatus.Unknown)))
                    .Select(staffMember => _staffMembers.Single(sm => sm.EmailAddress == staffMember.ScheduleId)) // as ScheduleInformation (staffMember) has only ScheduleId i.e. Email Address, we retrieve the whole BookingStaffMember from _staffMembers
                    .ToList();
            }
        }

        IsBusy = false;
    }

    [RelayCommand]
    private async Task ScheduleAppointmentAsync()
    {
        ValidateAllProperties();

        if (HasErrors)
        {
            Errors = string.Join(Environment.NewLine, GetErrors().Select(e => e.ErrorMessage));
        }
        else
        {
            IsBusy = true;

            HttpResponseMessage response;
            HttpRequestMessage request;

            Customer = new()
            {
                Id = "",
                DisplayName = App.UserContext.Name,
                EmailAddress = App.UserContext.Email,
                Phones = new List<Phone>(),
                Addresses = new List<PhysicalAddress>(),
            };

            if (CustomerPhone != null)
            {
                Customer.Phones.Add(new Phone { Number = CustomerPhone, Type = PhoneType.Mobile });
            }

            string jsonCustomer = JsonConvert.SerializeObject(Customer);

            request = new(HttpMethod.Post, CustomersGraphUrl);

            request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
            request.Content = new StringContent(jsonCustomer, Encoding.UTF8);
            request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");

            request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _msGraphAccessToken);
            response = await _httpClient.SendAsync(request).ConfigureAwait(false);

            response.EnsureSuccessStatusCode();
            string responseJson = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
            BookingCustomer createdCustomer = JsonConvert.DeserializeObject<BookingCustomer>(responseJson);

            DateTime appointmentDateTime = new
                (((DateTime)AppointmentDate).Year, ((DateTime)AppointmentDate).Month, ((DateTime)AppointmentDate).Day,
                SelectedTime.Time.Hour, SelectedTime.Time.Minute, SelectedTime.Time.Second);

            // Select a random staff member from the list of free staff members for the selected time slot
            Random random = new();
            int randomIndex = random.Next(0, SelectedTime.FreeStaffMembers.Count);
            string randomStaffMemberId = SelectedTime.FreeStaffMembers[randomIndex].Id;

            string customerTimeZone = TimeZoneInfo.Local.StandardName;

            //https://learn.microsoft.com/en-us/graph/api/bookingbusiness-post-appointments?view=graph-rest-1.0&tabs=csharp
            Appointment = new()
            {
                CustomerTimeZone = customerTimeZone,
                SmsNotificationsEnabled = true,
                EndDateTime = new DateTimeTimeZone
                {
                    DateTime = TimeZoneInfo.ConvertTimeToUtc(appointmentDateTime.AddHours(0.5)).ToString(DateTimeFormat), //"2023-07-20T12:30:00.0000000+00:00",
                    TimeZone = customerTimeZone,
                },
                IsLocationOnline = true,
                OptOutOfCustomerEmail = false,
                AnonymousJoinWebUrl = "",
                ServiceId = ServiceId,
                ServiceName = "30-min meeting",
                ServiceNotes = Notes ?? "",
                StartDateTime = new DateTimeTimeZone
                {
                    DateTime = TimeZoneInfo.ConvertTimeToUtc(appointmentDateTime).ToString(DateTimeFormat), //"2023-07-20T12:30:00.0000000+00:00",
                    TimeZone = customerTimeZone,
                },
                MaximumAttendeesCount = 5,
                FilledAttendeesCount = 1,
                StaffMemberIds = new List<string> { randomStaffMemberId /*"fb2fb357-e88c-4ccf-82e5-d45bb4957bb5"*/ },
                Customers = new List<BookingCustomerInformation>()
                {
                    new BookingCustomerInformation
                    {
                        CustomerId = createdCustomer.Id,//"e9fee3ae-ff42-4524-b0f9-53cbbca39b84",
                        Name = App.UserContext.Name,
                        EmailAddress = App.UserContext.Email,
                        Phone = CustomerPhone,
                        Notes = Notes,
                        TimeZone = customerTimeZone,
                    },
                },
            };

            string jsonAppontment = JsonConvert.SerializeObject(Appointment);

            request = new(HttpMethod.Post, ApptsGraphUrl);

            request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
            request.Content = new StringContent(jsonAppontment, Encoding.UTF8);
            request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");

            request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _msGraphAccessToken);
            response = await _httpClient.SendAsync(request).ConfigureAwait(false);

            response.EnsureSuccessStatusCode();
            string apptResponseContent = await response.Content.ReadAsStringAsync();

            // https://www.youtube.com/watch?v=dWdXXGa1_hI
            NotificationRequest notificationRequest = new()
            {
                Title = "Your video call with LATICRETE starts soon",
                Subtitle = "Your video call with Laticrete is in 30 minutes!",
                Description = "Your video call starts in 30 minutes. Open the app to get started",
                BadgeNumber = 1,
                CategoryType = NotificationCategoryType.Event,
                Schedule = new NotificationRequestSchedule()
                {
                    NotifyTime = appointmentDateTime.AddMinutes(-30)
                }
            };

            await InitializeAsync();

            IsBusy = false;

            await LocalNotificationCenter.Current.Show(notificationRequest);

            await Shell.Current.Navigation.PopAsync();
        }
    }
}

}

0

There are 0 best solutions below