Thursday, December 18, 2014

Using RegionContext in Prism 5

Сегодняшняя статья посвящена продолжении темы навигации в Prism 5. Я много времени потратил на написание примера, поскольку никак не мог придумать пример, который был бы простым, но и раскрывал суть рассмотренной темы. Особенно сложно это бывает в том случае, когда в интернете нет нормального описания той темы, которую ты пытаешься донести читателям. Сегодня мы рассмотрим, как можно шарить данные между представлениями в Prism 5. Поговорим об использовании RegionContext для синхронизации данных. Так как в интернете очень мало информации о том, как вообще можно управлять RegionContext с использованием паттерна MVVM, а именно – с модели представления, это создает дополнительные сложности. Если вы используете Prism в своих проектах, то благодаря этой теме сможете узнать, как не натыкаться на те же шишки, на которые натыкался я. Пример получился не очень сложным, и надеюсь, что он будет понятен для читателей. Ниже представлена структура проекта, в которой основную часть составляют модели.
Давайте воссоздадим у себя последовательно структуру этого проекта. Для этого создаем в Visual Studio новый WPF проект и назовем его “SharingDataBetweenRegions”. Учтите при создании проекта, что .NET Framework должен быть указан не ниже, чем версия 4.5, поскольку только с этой версии работает Prism 5.
Следующим делом заходим в NuGet Package Manager и устанавливаем библиотеки Prism и Prism.UnityExtensions.
Prism.UnityExtensions – это расширения для IoC контейнера Unity. Вместо MEF, я предпочитаю использовать какой-либо из популярных IoC контейнеров. В основном выбор падает либо на Unity, либо на Autofac. В последнее время больше приходится работать все-таки с Unity из-за того, что он используется в большинстве проектов, с которыми приходится сталкиваться. Надеюсь, что вы уже знакомы с Prism хоть немного, и такие понятия, как Shell, RegionManager, Bootstrapper не вызывают у вас непонимания. Если же все-таки эти названия звучат как заклинания, тогда вам лучше вначале начать ознакомление с Prism c этих статей:  "Введение в Prism 5. Bootstrapper" и "Введение в Prism 5. Работа с регионами". Эти статьи, по сути, – самые азы, которые нужны для того чтобы работать с Prism.
А мы продолжим написание нашего проекта. Первым делом нужно разбить наш проект на такую структуру папок:
·        Helpers – разные вспомогательные классы для проекта;
·        Models – модели с паттерна MVVM;
·        Modules – модули с Prism;
·        ViewModels – модели представления;
·        Views – представления;
·  Regions – контролы, которые используются для заполнения регионов.
Вроде, все очень кратко, и пока ничего не упустил. Затем перейдем к реализации наших моделей, при этом кратко описывая функции модели. Начнем наше описание с интерфейса IBook. Этот интерфейс необходимый для того, чтобы показать структуру книги.
public interface IBook
{
    string Author { get; set; }
    string Title { get; set; }
    DateTime Year { get; set; }
    string SN { get; set; }
    int Count { get; set; }
    double Cost { get; set; }
}
В результате у нас выйдет что-то в виде системы, которая позволяет посмотреть, кто приобрел какую книгу и в каком количестве. Это вкратце о том, что будет делать наш проект. Реализация же интерфейса IBook приведена в классе Book.
public class Book : BindableBase, IBook
{
    private string _author;
    public string Author
    {
        get { return _author; }
        set
        {
            SetProperty(ref _author, value);
        }
    }

    private string _title;
    public string Title
    {
        get { return _title; }
        set
        {
            SetProperty(ref _title, value);
        }
    }

    private DateTime _year;
    public DateTime Year
    {
        get { return _year; }
        set
        {
            SetProperty(ref _year, value);
        }
    }

    private string _sn;
    public string SN
    {
        get { return _sn; }
        set
        {
            SetProperty(ref _sn, value);
        }
    }

    private int _count;
    public int Count
    {
        get { return _count; }
        set
        {
            SetProperty(ref _count, value);
        }
    }

    private double _cost;
    public double Cost
    {
        get { return _cost; }
        set { SetProperty(ref _cost, value); }
    }
}
Для тех, кто читает мой блог, может показаться немного странным использование функции SetProperty, для того чтобы установить значения для свойства и автоматически вызвать функцию OnPropertyChanged, вместо записей вроде этих:
private double _cost;
public double Cost
{
    get { return _cost; }
    set
    {
        _cost = value;
        OnPropertyChanged(() => Cost);
    }
}
Или же
private double _cost;
public double Cost
{
    get { return _cost; }
    set
    {
        _cost = value;
        OnPropertyChanged("Cost");
    }
}
Это я сделал больше для того, чтобы показать, что такой тип записи тоже имеет место быть. Тем более, функция SetProperty выполняет, по сути, два действия. Первое: устанавливает значение нужной переменной. Второе: вызывает функцию OnPropertyChanged, для того чтобы указать представлению, что данные обновились.
Следующим интерфейсом будет интерфейс ICustomer, который будет отображать информацию о покупателях.
public interface ICustomer
{
    int Id { get; set; }
    string FirstName { get; set; }
    string LastName { get; set; }
}
Этот интерфейс очень примитивный, и комментировать его излишне. Поэтому просто давайте посмотрим на имплементацию данного интерфейса.
public class Customer : BindableBase, ICustomer
{
    private int _id;
    public int Id
    {
        get { return _id; }
        set
        {
            SetProperty(ref _id, value);
        }
    }

    private string _firstName;
    public string FirstName
    {
        get
        {
            return _firstName;
        }
        set
        {
            SetProperty(ref _firstName, value);
        }
    }

    private string _lastName;
    public string LastName
    {
        get
        {
            return _lastName;
        }
        set
        {
            SetProperty(ref _lastName, value);
        }
    }
}
Затем у нас есть еще интерфейс IOrder, который будет хранить информацию о покупке книг покупателем.
public interface IOrder
{
    int Id { get; set; }
    int CustomerId { get; set; }
    double Price { get; set; }
    List<IBook> BuyList { get; set; }
    int Count { get; }
}
Здесь свойство Count, вероятнее всего, излишнее, так как информацию о количестве книг мы можем получить из свойства BuyList, которое тоже названо не очень красиво, к сожалению. Реализация IOrder приведена в классе Order ниже.
public class Order : BindableBase, IOrder
{
    private int _id;
    public int Id
    {
        get
        {
            return _id;
        }
        set
        {
            SetProperty(ref _id, value);
        }
    }

    private int _customerId;
    public int CustomerId
    {
        get
        {
            return _customerId;
        }
        set
        {
            SetProperty(ref _customerId, value);
        }
    }

    private double _price;
    public double Price
    {
        get
        {
            return _price;
        }
        set
        {
            SetProperty(ref _price, value);
        }
    }

    public int Count
    {
        get { return BuyList.Count; }
    }

    private List<IBook> _buyList; 
    public List<IBook> BuyList
    {
        get { return _buyList; }
        set { SetProperty(ref _buyList, value); }
    }
}
Осталась последняя модель OrderInfo, которая будет отображать информацию о покупке конкретной книги в модели. Это вспомогательная модель только для отображения.
public class OrderInfo : BindableBase
{
    private string _firstName;
    public string FirstName
    {
        get
        {
            return _firstName;
        }
        set
        {
            SetProperty(ref _firstName, value);
        }
    }

    private string _lastName;
    public string LastName
    {
        get
        {
            return _lastName;
        }
        set
        {
            SetProperty(ref _lastName, value);
        }
    }

    private double _price;
    public double Price
    {
        get
        {
            return _price;
        }
        set
        {
            SetProperty(ref _price, value);
        }
    }

    private int _count;
    public int Count
    {
        get { return _count; }
        set { SetProperty(ref _count, value); }
    }
}
Самая примитивная логика у нас заняла довольно-таки много времени и прилично места в статье. Но она необходима, для того чтобы сложить всю цепочку воедино. Затем нам нужно наше главное окно в перенести папку View и переименовать его с MainWindow в Shell, чтобы оперировать терминами Prism. Посмотрите, чтобы у вас в коде не было названий MainWindow. Не забудьте также убрать начальную загрузку главного окна с App.xaml, в котором запуск окна указывается параметром StartupUri. Удалите StartupUri со значением совсем. Загрузка главного окна будет лежать на бутстраппере. Как его реализовать, мы вернемся несколько позже. А для начала нужно сделать разметку для главного окна (Shell.xaml). Выглядит она следующим образом.
<Window x:Class="SharingDataBetweenRegions.Shell"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:regions="http://www.codeplex.com/CompositeWPF"
        Title="MainWindow" Height="350" Width="525">
  <Window.Background>
    <LinearGradientBrush EndPoint="0.5,1" StartPoint="0.5,0">
      <GradientStop Color="#FFFFFFFF" Offset="0"/>
      <GradientStop Color="#FCFFF5" Offset="0.992"/>
      <GradientStop Color="#3E606F" Offset="0.185"/>
    </LinearGradientBrush>
  </Window.Background>
  <Grid>
    <Grid.RowDefinitions>
      <RowDefinition Height="50"/>
      <RowDefinition Height="*"/>
    </Grid.RowDefinitions>
    <Border Margin="10,5,10,10" Grid.Row="1" CornerRadius="4,4,4,4" BorderBrush="#193441" Background="#FFC8DDC5" BorderThickness="2,2,2,2">
      <Grid Width="Auto" Height="Auto" Margin="10,10,10,10">
        <Grid.ColumnDefinitions>
          <ColumnDefinition Width=".4*"/>
          <ColumnDefinition  Width=".6*"/>
        </Grid.ColumnDefinitions>
        <Border Grid.Column="0" CornerRadius="4,4,4,4" BorderBrush="#193441" Background="#FCFFF5" BorderThickness="2,2,2,2" Margin="5" Padding="5">
          <StackPanel Orientation="Vertical">
            <ContentControl regions:RegionManager.RegionName="BookRegion" />
          </StackPanel>
        </Border>
        <Border Grid.Column="1" CornerRadius="4,4,4,4" BorderBrush="#193441" Background="#FCFFF5" BorderThickness="2,2,2,2" Margin="5" Padding="5">
          <StackPanel Orientation="Vertical">
            <ItemsControl regions:RegionManager.RegionName="OrderRegion"  />
          </StackPanel>
        </Border>
      </Grid>
    </Border>
  </Grid>
</Window>
В дизайнере это чудо выглядит вот так:
В левой части мы сделаем возможность выбора книги, а в правой части будет выведена информация о покупателях, цене и количестве купленных книг. Приступим к реализации заполнителя для региона BookRegion. Для этого перейдем в папку Regions и добавим новый UserControl с именем BookView.
<UserControl x:Class="SharingDataBetweenRegions.Views.Regions.BookView"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             mc:Ignorable="d"
             d:DesignHeight="300" d:DesignWidth="300">
    <Grid>
      <ComboBox Name="CustomerCbx" Margin="5"  Width="Auto" HorizontalAlignment="Stretch"
                ItemsSource="{Binding Books}"
                SelectedItem="{Binding SelectedBook}">
        <ComboBox.ItemTemplate>
          <DataTemplate>
            <StackPanel Orientation="Horizontal">
              <TextBlock Text="{Binding Author}" />
              <TextBlock Text="{Binding Title}" Margin="5,0,0,0"/>
              <TextBlock Text="{Binding Cost}" Margin="5,0,0,0"/>
            </StackPanel>
          </DataTemplate>
        </ComboBox.ItemTemplate>
      </ComboBox>
    </Grid>
</UserControl>
Реализация очень примитивная и представляет собой обычный ComboBox переопределённым стилем для отображения данных. Вы, конечно же, можете пойти дальше и написать просто свой адаптер (класс IRegionAdapter), для того чтобы использовать ComboBox явно.  Если вы все-таки захотите это реализовать, то можете воспользоваться готовым адаптером ItemsControlRegionAdapter.  Возможно, мы рассмотрим тему адаптеров в какой-то из следующих статей, поскольку примеров по использованию адаптеров в призме очень мало. Нам же осталось реализовать заполнитель, который будет использоваться для региона OrderRegion. Для этого также в папке Regions создадим новый UserControl с именем OrderView. Это представление также очень простое и представляет собой простой список с переопределённый темплейтом для отображения данных.
<UserControl x:Class="SharingDataBetweenRegions.Views.Regions.OrderView"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             mc:Ignorable="d"
             d:DesignHeight="300" d:DesignWidth="300">
    <Grid>
      <StackPanel>
        <ListBox ItemsSource="{Binding OrderDetails}">
          <ListBox.ItemTemplate>
            <DataTemplate>
              <StackPanel Orientation="Horizontal">
                <TextBlock Text="{Binding FirstName}" />
                <TextBlock Text="{Binding LastName}" Margin="5,0,0,0" />
                <TextBlock FontWeight="Bold" Text="Price:" Margin="5,0,0,0"/>
                <TextBlock Text="{Binding Price}" Margin="5,0,0,0"/>
                <TextBlock FontWeight="Bold" Text="Count:" Margin="5,0,0,0"/>
                <TextBlock Text="{Binding Count}" Margin="5,0,0,0"/>
              </StackPanel>
            </DataTemplate>
          </ListBox.ItemTemplate>
        </ListBox>
      </StackPanel>
    </Grid>
</UserControl>
Поскольку с представлениями мы закончили, по сути, осталось для каждого из этих представлений написать соответственную модель представления и связать ее с нужным представлением. Начнем, пожалуй, с модели представления для региона BookRegion. Для этого перейдем в папку ViewModels и создадим класс BookViewModel.
public class BookViewModel : BindableBase
{
    private readonly IRegionManager _regionManager;

    #region Constructor
    public BookViewModel(IRegionManager manager)
    {
        _regionManager = manager;
        Books = new ObservableCollection<IBook>(Helpers.HelperGenerator.GenerateBooks());
    }
    #endregion

    public ObservableCollection<IBook> Books { get; set; }

    private IBook _selectedBook;

    public IBook SelectedBook
    {
        get { return _selectedBook; }
        set
        {
            SetProperty(ref _selectedBook, value);
            _regionManager.Regions["BookRegion"].Context = _selectedBook;
        }
    }
}
Только вот проблема, что если вы добавите себе это класс, то он у вас сразу подсветит строку
Books = new ObservableCollection<IBook>(Helpers.HelperGenerator.GenerateBooks());
Все дело в том, что мы не добавили себе класс HelperGenerator. Этот статический класс, по сути, генерирует нам фейковые данные, для того чтобы проверить работоспособность нашего творения. Обычно такой подход используют дизайнеры, чтобы проверить, что их творение работает нормально. Ниже приведена реализация данного класса со всеми методами.
public static class HelperGenerator
{
    public static List<IBook> GenerateBooks()
    {
        var cshapInDepth = ServiceLocator.Current.GetInstance<IBook>();
        cshapInDepth.Author = "Jon Skeet";
        cshapInDepth.Title = "C# in Depth";
        cshapInDepth.Count = 3;
        cshapInDepth.SN = "ISBN: 9781617291340";
        cshapInDepth.Year = new DateTime(2013, 9, 10);
        cshapInDepth.Cost = 44.3;

        var refactoringBook = ServiceLocator.Current.GetInstance<IBook>();
        refactoringBook.Author = "Martin Fowler";
        refactoringBook.Title = "Refactoring: Improving the Design of Existing Code";
        refactoringBook.Count = 2;
        refactoringBook.SN = "ISBN-10: 0201485672";
        refactoringBook.Year = new DateTime(1999, 7, 8);
        refactoringBook.Cost = 52;

        var clrViaCsharp = ServiceLocator.Current.GetInstance<IBook>();
        clrViaCsharp.Author = "Jeffrey Richter";
        clrViaCsharp.Title = "CLR via C# (Developer Reference)";
        clrViaCsharp.Count = 5;
        clrViaCsharp.SN = "ISBN-10: 0735667454";
        clrViaCsharp.Year = new DateTime(2012, 12, 4);
        clrViaCsharp.Cost = 32.5;

        return new List<IBook>
        {
            cshapInDepth,
            refactoringBook,
            clrViaCsharp
        };
    }

    public static List<ICustomer> GenerateCustomers()
    {
        var firstCustomer = ServiceLocator.Current.GetInstance<ICustomer>();
        firstCustomer.Id = 1;
        firstCustomer.FirstName = "Aleksandr";
        firstCustomer.LastName = "Polukhovich";

        var secondCustomer = ServiceLocator.Current.GetInstance<ICustomer>();
        secondCustomer.Id = 2;
        secondCustomer.FirstName = "Irina";
        secondCustomer.LastName = "Polukhovich";

        var thirdCustomer = ServiceLocator.Current.GetInstance<ICustomer>();
        thirdCustomer.Id = 3;
        thirdCustomer.FirstName = "Viktoria";
        thirdCustomer.LastName = "Polukhovich";
           
        return new List<ICustomer>
        {
            firstCustomer,
            secondCustomer,
            thirdCustomer
        };
    }

    public static List<IOrder> GenerateOrders()
    {
        var books = GenerateBooks();
        var customers = GenerateCustomers();

        var orders = new List<IOrder>();
        int i = 1;
        foreach (var customer in customers)
        {
            var order = ServiceLocator.Current.GetInstance<IOrder>(); ;
            order.BuyList = books;
            order.CustomerId = customer.Id;
            order.Id = i;
            order.Price = books.Sum(x => x.Cost);
            i++;

            orders.Add(order);
        }

        return orders;
    }
}
Добавляйте себе этот класс в папку Helpers. Она для этого и предназначена в данном проекте. Следующим любопытным моментом будет установка свойства Context для региона.
public IBook SelectedBook
{
    get { return _selectedBook; }
    set
    {
        SetProperty(ref _selectedBook, value);
        _regionManager.Regions["BookRegion"].Context = _selectedBook;
    }
}
Как только мы выбираем другую книгу, то автоматически устанавливаем данную книгу как контекст для региона. После этого тот регион, который должен реагировать на изменение контекста BookRegion, будет реализовывать свою логику. Как это будет выглядеть, мы увидим в следующей модели представления CustomerOrderViewModel. Этот класс, как мы уже помним, нужно добавить в папку ViewModels. В этом классе мы добавим функцию UpdateOrderDetails, которая будет принимать значение книги, по которой мы хотим узнать всю интересующую нас информацию.
public class CustomerOrderViewModel
{
    #region Constructor
    public CustomerOrderViewModel()
    {
        Orders = new ObservableCollection<IOrder>(Helpers.HelperGenerator.GenerateOrders());
        Customes = new ObservableCollection<ICustomer>(Helpers.HelperGenerator.GenerateCustomers());
        OrderDetails = new ObservableCollection<OrderInfo>();
    }
    #endregion

    public ObservableCollection<IOrder> Orders { get; set; }
    public ObservableCollection<ICustomer> Customes { get; set; }

    public ObservableCollection<OrderInfo> OrderDetails { get; set; }

    public void UpdateOrderDetails(IBook book)
    {
        if(book == null)
            return;

        OrderDetails.Clear();
        foreach (var order in Orders)
        {
            var books = order.BuyList.Where(x => x.Author == book.Author &&
                                                    x.SN == book.SN).ToList();

            var customer = Customes.First(c => c.Id == order.CustomerId);
            var orderInfo = new OrderInfo
            {
                FirstName = customer.FirstName,
                LastName = customer.LastName,
                Count = books.Count,
                Price = books.Sum(x => x.Cost)
            };
            OrderDetails.Add(orderInfo);
        }
    }
}
Логика упрощена по максимуму. Никаких команд, навигаций и других плюшек призма. Только та тема, которую мы рассматриваем на протяжении данной статьи, а именно: работа с контекстом региона. Теперь нам нужно связать наши представления с моделями представления. Тут нам приходит на помощь понятие модулей с Prism. Для того чтобы создать новый модуль, достаточно перейти в папку Modules и создать новый класс MainModule, который необходимо наследовать от интерфейса IModule.
public class MainModule : IModule
{
    #region Variables

    private readonly IRegionManager _regionManager;
    private readonly IUnityContainer _container;
    #endregion

    #region Constructor
    public MainModule(IRegionManager regionManager, IUnityContainer container)
    {
        _regionManager = regionManager;
        _container = container;
    }
    #endregion

    #region Public Methods
    public void Initialize()
    {
        var bookViewModel = _container.Resolve<BookViewModel>();
        var bookView = _container.Resolve<BookView>();
        bookView.DataContext = bookViewModel;

        var customerViewModel = _container.Resolve<CustomerOrderViewModel>();
        var orderView = _container.Resolve<OrderView>();
        orderView.DataContext = customerViewModel;

        var regionContext = RegionContext.GetObservableContext(bookView);
        regionContext.PropertyChanged += (s, e)
            => customerViewModel.UpdateOrderDetails(
                regionContext.Value as IBook);

        _regionManager.Regions["BookRegion"].Add(bookView);
        _regionManager.Regions["OrderRegion"].Add(orderView);
    }
    #endregion
}
Поскольку свойство RegionContext изменяется и ему присваиваются новые значения в модели представления BookViewModel, нам необходимо реагировать на эти изменения. Для этого нужно подписаться на событие PropertyChanged в обработчик события RegionContextChanged. Основная логика происходит в этих строках:
var regionContext = RegionContext.GetObservableContext(bookView);
regionContext.PropertyChanged += (s, e)
    => customerViewModel.UpdateOrderDetails(
        regionContext.Value as IBook);
Теперь давайте пройдемся по тому, что же здесь происходит. Метод GetObservableContext возвращает класс ObservableObject<T>, который является оберткой над значением RegionContext. Это свойство может быть установлено на любое из представлений. Для этих целей даже существуют разные behaviours, которые позволяют задать поведение RegionContext с XAML (ссылка на MSDN). Как обычно, описание этих поведений минимальное, и в интернете примеров нет. Поэтому если вы хотите попробовать использовать для своего RegionContext какое-то специфическое поведение, вам нужно будет самому набить себе шишки и наступить на все грабли впервые. Некоторые вещи призма до сих пор очень плохо документированы. Возможно, разработчики предполагали, что их никогда не будут использовать.
Как указано выше, для того чтобы следить за изменением RegionContext, мы подписываемся на событие PropertyChanged и затем передаем значение с данного контекста в метод UpdateOrderDetails модели представления CustomerOrderViewModel. Теперь остался финальный шаг, чтобы связать это все воедино, и чтобы у нас это все запустилось. Для этого нам понадобится загрузчик, который необходимо наследовать от UnityBootstrapper. Назовем этот загрузчик Bootstrapper и добавим его к корень проекта.
public class Bootstrapper : UnityBootstrapper
{
    protected override DependencyObject CreateShell()
    {
        return Container.Resolve<Shell>();
    }

    protected override void InitializeShell()
    {
        Application.Current.MainWindow = (Window) Shell;
        Application.Current.MainWindow.Show();
    }

    protected override void InitializeModules()
    {
        IModule module = Container.Resolve<MainModule>();
        module.Initialize();
    }

    protected override void ConfigureContainer()
    {
        RegisterTypeIfMissing(typeof(IBook), typeof(Book), false);
        RegisterTypeIfMissing(typeof(ICustomer), typeof(Customer), false);
        RegisterTypeIfMissing(typeof(IOrder), typeof(Order), false);

        base.ConfigureContainer();
    }
}
У него есть множество функций. Здесь приведена лишь часть из них. Методы своим названием отражают тот факт, что эти методы делают. Например, метод CreateShell создает главное окно, InitializeShell задает начальные параметры запуска.
Теперь осталось запустить сам Bootstrapper, чтобы он сделал всю магию, которая в нем предусмотрена, и мы получили тот результат, который ожидаем. Для этого нужно перейти в класс App.xaml.cs и переопределить метод OnStartup, как показано ниже в примере.
protected override void OnStartup(StartupEventArgs e)
{
    var bootstrapper = new Bootstrapper();
    bootstrapper.Run();
}
После этого смело нажимаем на кнопку Run и смотрим, что у нас получилось.

На этом, пожалуй, завершу данную статью. Надеюсь, что данное краткое введение помогло вам понять, как использовать RegionContext для синхронизации. Буду рад за комментарии к статье, если какие-то нюансы остались непонятными, или какие темы по призму вы бы хотели еще увидеть. 

No comments:

Post a Comment