Developpement Windows Phone – partie 25


Cet article fait partie d’une série d’articles sur le développement Windows Phone. Il s’agit d’une traduction des articles se trouvant sur la MSDN.

Sommaire

Bien débuter et fondamentaux

Visuels et média

Travailler avec les données

Sondes et autres fonctionnalités spécifiques au téléphone


Détection de Mouvements (Accéléromètre )

Silverlight pour Windows Phone contient un namespace dédié à la gestion et l’exploitation en temps réel des données de l’accéléromètre du téléphone. L’accéléromètre mesure l’intensité et la direction de la force d’accélération appliquée sur le téléphone. Cette force est exprimée sous forme de variable décimale dont les valeurs s’étendent de -1.0 à 1.0. Cette valeur d’intensité est fournie pour les axes X, Y et Z du téléphone, correspondant à la force d’accélération subie par le téléphone respectivement en largeur, longueur et profondeur. Pour déterminer la direction de la force d’accélération, ces valeurs doivent être comparées les unes aux autres. Bien que le calcul de la direction ne soit pas couvert dans ce tutoriel, nous verrons comment récupérer les valeurs fournies par l’accéléromètre afin que la comparaison de ces valeurs soit simple lorsque vous utiliserez l’accéléromètre dans vos applications ou des jeux.

Cette application est une application Silverlight pour Windows Phone qui reçoit des données provenant directement du téléphone Windows Phone 7, et qui représente graphiquement ces données sur un canvas. L’étude de cette application dans ce tutoriel se découpe en plusieurs sections :

Téléchargez le code source complet de ce tutoriel, hébergé dans la Silverlight pour Windows Phone Code Gallery.

 

Démonstration Vidéo

Pour voir un exemple vidéo de l’utilisation de l’application détaillée dans ce tutoriel ainsi que du développement tel que décrit vous pouvez regarder la vidéo suivante. La page web de cette vidéo sur Channel 9 propose plusieurs formats et qualités, vous pouvez ainsi la regarder en haute définition ou la télécharger pour la lire sur des périphériques différents d’un ordinateur.

 

Créer l’interface utilisateur pour l’affichage des données de l’accéléromètre

Pour représenter les valeurs actuellement fournies par l’accéléromètre de façon pertinente, il est très utile d’afficher à la fois les valeurs (numériques) à l’instant précis mais aussi les dernières valeurs (sous forme de graphique). Pour faire cela, nous allons créer une interface utilisateur (UI) contenant les éléments suivants :

  • 3 TextBlock avec des noms significatifs pour les valeurs représentées pour les axes X, Y et Z.
  • Un Canvas avec un nom significatif sur lequel nous ajouterons de petits rectangles pour chaque point dessiné
  • Un Button avec un nom significatif qui sera utilisé pour démarrer/arrêter l’accéléromètre.
  • Les TextBlocks nécessaires pour les labels des valeurs capturées depuis l’accéléromètre.
  • 3 Rectangles qui seront coloriés avec différentes couleurs et qui serviront de légende pour les axes.

La disposition de ces éléments peut ressembler à ceci :

clip_image003

XAML
  1. <Grid x:Name="LayoutRoot" Background="Transparent">
  2.     <Grid.RowDefinitions>
  3.         <RowDefinition Height="Auto"/>
  4.         <RowDefinition Height="*"/>
  5.     </Grid.RowDefinitions>
  6.     <!--TitlePanel contains the name of the application and page title-->
  7.     <StackPanel x:Name="TitlePanel" Grid.Row="0" Margin="12,17,0,28">
  8.         <TextBlock x:Name="ApplicationTitle" Text="JoMul's Demo"
  9.                    Style="{StaticResource PhoneTextNormalStyle}"/>
  10.         <TextBlock x:Name="PageTitle" Text="Accelerometer" Margin="9,-7,0,0"
  11.                    Style="{StaticResource PhoneTextTitle1Style}"/>
  12.     </StackPanel>
  13.     <!--ContentPanel - place additional content here-->
  14.     <Grid x:Name="ContentPanel" Grid.Row="1" Margin="12,0,12,0">
  15.         <TextBlock Height="30" HorizontalAlignment="Left" Margin="46,20,0,0"
  16.                    Name="textBlock1" Text="X:" VerticalAlignment="Top" />
  17.         <TextBlock Height="30" HorizontalAlignment="Left" Margin="46,56,0,0"
  18.                    Name="textBlock2" Text="Y:" VerticalAlignment="Top" />
  19.         <TextBlock Height="30" HorizontalAlignment="Left" Margin="47,92,0,0"
  20.                    Name="textBlock3" Text="Z:" VerticalAlignment="Top" />
  21.         <Rectangle Height="20" HorizontalAlignment="Left" Margin="18,25,0,0"
  22.                    Name="rectangle1" Fill="Red" StrokeThickness="1"
  23.                    VerticalAlignment="Top" Width="16" />
  24.         <Rectangle Fill="Blue" Height="20" HorizontalAlignment="Left"
  25.                    Margin="18,60,0,0" Name="rectangle2" StrokeThickness="1"
  26.                    VerticalAlignment="Top" Width="16" />
  27.         <Rectangle Fill="Green" Height="20" HorizontalAlignment="Left"
  28.                    Margin="18,95,0,0" Name="rectangle3" StrokeThickness="1"
  29.                    VerticalAlignment="Top" Width="16" />
  30.         <TextBlock Height="30" HorizontalAlignment="Left" Margin="82,20,0,0"
  31.                    Name="xreadout" Text="1.0" VerticalAlignment="Top" />
  32.         <TextBlock Height="30" HorizontalAlignment="Left" Margin="82,56,0,0"
  33.                    Name="yreadout" Text="1.0" VerticalAlignment="Top" />
  34.         <TextBlock Height="30" HorizontalAlignment="Left" Margin="82,92,0,0"
  35.                    Name="zreadout" Text="1.0" VerticalAlignment="Top" />
  36.         <Button Content="PAUSE" Height="97" HorizontalAlignment="Left"
  37.                 Margin="143,25,0,0" Name="PlayOrPause" VerticalAlignment="Top"
  38.                 Width="285" Click="PlayOrPause_Click" />
  39.         <Canvas Height="400" HorizontalAlignment="Left" Margin="18,140,0,0"
  40.                 Name="Log" VerticalAlignment="Top" Width="400"
  41.                 Background="White" />
  42.         <TextBlock Height="30" HorizontalAlignment="Left" Margin="419,326,0,0"
  43.                    Name="textBlock4" Text="0" VerticalAlignment="Top" Width="30" />
  44.         <TextBlock Height="30" HorizontalAlignment="Left" Margin="419,128,0,0"
  45.                    Name="textBlock5" Text="1.0" VerticalAlignment="Top" />
  46.         <TextBlock Height="30" HorizontalAlignment="Left" Margin="419,526,0,0"
  47.                    Name="textBlock6" Text="-1.0" VerticalAlignment="Top" />
  48.     </Grid>
  49. </Grid>

 

Lire les données de l’accéléromètre en temps réel

Silverlight pour Windows Phone utilise l’assembly Microsoft.Devices.Sensors pour gérer les données de l’accéléromètre. Cette assembly doit être référencée par votre projet avant de pouvoir utiliser dans votre application les types, évènements ou méthodes qui la composent.

Pour ajouter Microsoft.Devices.Sensors à votre application :

1. Dans le Solution Explorer, faites un clic droit sur le nœud References de votre projet, et sélectionner ensuite Add Reference.

2. Sélectionnez Microsoft.Devices.Sensors dans la liste, et cliquez sur OK.

3. Ajoutez le code suivant au début de chaque fichier source qui devra utiliser les classes et méthodes liées à l’accéléromètre.

C#
  1. using Microsoft.Devices.Sensors;

 

Visual Studio
  1. Imports Microsoft.Devices.Sensors

L’étape suivant est d’ajouter un membre de type Accelerometer à la classe dont les méthodes devront utiliser les données de l’accéléromètre.

C#
  1. public partial class MainPage : PhoneApplicationPage
  2. {
  3.     // Constructor
  4.     Accelerometer accelerometer = new Accelerometer();
  5.     ...
Visual Basic
  1. Partial Public Class MainPage
  2.     Inherits PhoneApplicationPage
  3.     ' Constructor
  4.     Private accelerometer As New Accelerometer()
  5.     ..

Ce membre devra être visible dans toute la classe, car deux méthodes doivent être utilisées pour lire les données de l’accéléromètre :

1. Un event handler qui est appelé lorsque l’accéléromètre lève l’évènement ReadingChanged.

2. Une méthode s’occupant d’associer l’event handler à l’évènement ReadingChanged.

Commençons par la première. Tous les event handlers prennent au moins deux paramètres en argument : un "object" qui référence l’objet qui a levé l’évènement (dans notre cas l’instance de la classe Accelerometer définie en membre de classe), et un "event args" dont les propriétés contiennent les données relatives à l’évènement. Notre event handler pour l’évènement ReadingChanged de l’Accelerometer ressemblera à ceci :

C#
  1. void myHandler(object sender, AccelerometerReadingEventArgs e)
  2. {
  3.     // TODO: Code here
  4. }
Visual Basic
  1. Private Sub myHandler(sender As Object, e As AccelerometerReadingEventArgs)
  2.     ' TODO: Code here
  3. End Sub

Nous avons besoin d’initialiser le membre de type Accelerometer et de lier l’évènement ReadingChanged à myHandler (par souci de simplicité et uniquement pour ce tutoriel nous allons faire ceci dans la fonction principale).

C#
  1. public MainPage()
  2. {
  3.     InitializeComponent();
  4.     accelerometer.Start();
  5.     accelerometer.ReadingChanged += new
  6.     EventHandler<AccelerometerReadingEventArgs>(myHandler);
  7. }
Visual Basic
  1. Public Sub New()
  2.     InitializeComponent()
  3.     accelerometer.Start()
  4.     accelerometer.ReadingChanged += New EventHandler(Of AccelerometerReadingEventArgs)(myHandler)
  5. End Sub

Nous nous trouverons alors dans une impasse. Nous avons besoin de mettre à jour l’UI avec les données provenant de l’accéléromètre ; mais comme l’évènement ReadingChanged est levé depuis le thread de l’accéléromètre, la méthode myHandler sera aussi exécutée dans ce thread. Or nous souhaitons que la méthode myHandler puisse mettre à jour l’UI avec les valeurs de l’accéléromètre, et l’UI est gérée dans son propre thread, la rendant inaccessible. Heureusement, la solution à cette problématique est une simple ligne de code utilisant l’objet Dispatcher pour appeler une fonction sur le thread UI qui fera la mise à jour pour nous en lui passant l’objet AccelerometerReadingEventArgs qui contient les données que l’on veut afficher. Nous appellerons cette fonction updateMyScreen. myHandler ressemble maintenant à ceci :

C#
  1. void myHandler(object sender, AccelerometerReadingEventArgs e)
  2. {
  3.     Deployment.Current.Dispatcher.BeginInvoke(() => updateMyScreen(e));
  4. }

 

Visual Studio
  1. Private Sub myHandler(sender As Object, e As AccelerometerReadingEventArgs)
  2.     Deployment.Current.Dispatcher.BeginInvoke(Function() updateMyScreen(e))
  3. End Sub

Vous êtes maintenant prêt à brancher les données de l’accéléromètre à l’UI. Ceci est rendu simple par les propriétés de l’objet AccelerometerReadingEventArgs qui contiennent les valeurs pour les axes X, Y et Z. Nous allons terminer cette section en mettant en place un affichage très simple des données de l’accéléromètre : une simple zone de texte contenant la valeur. updateMyScreen, qui s’exécute sur le thread UI est appelé à chaque fois que l’évènement ReadingChanged est levé, a juste besoin de mettre à jour la propriété Text du TextBlock correspondant à chaque axe.

C#
  1. void updateMyScreen(AccelerometerReadingEventArgs e)
  2. {
  3.     // updates the textblocks
  4.     xreadout.Text = e.X.ToString("0.00");
  5.     yreadout.Text = e.Y.ToString("0.00");
  6.     zreadout.Text = e.Z.ToString("0.00");
  7. }
Visual Basic
  1. Private Sub updateMyScreen(e As AccelerometerReadingEventArgs)
  2.     ' updates the textblocks
  3.     xreadout.Text = e.X.ToString("0.00")
  4.     yreadout.Text = e.Y.ToString("0.00")
  5.     zreadout.Text = e.Z.ToString("0.00")
  6. End Sub

L’objet AccelerometerReadingEventArgs représente le force d’accélération exercée selon les axes X, Y et Z sous forme de double (valeur numérique à virgule flottante). Ces valeurs sont converties en string avec une précision à deux chiffres avec la virgule en utilisant la méthode ToString à laquelle on passe l’argument de format "0.00".

Vous pouvez maintenant compiler l’application et visualiser les données de l’accéléromètre à l’écran. Nous avons vu les éléments de base de la lecture des données de l’accéléromètre, nous allons en plus de cela ajouter une représentation graphique utilisant des éléments Rectangle et le Canvas qui a été ajouté à l’UI précédemment ce qui vous aidera a réellement voir ce que l’accéléromètre est en train de faire lorsque vous essaierez d’utiliser ses données dans vos applications. Il est fortement conseillé de continuer ce tutoriel ; toutefois si vous ne souhaitez qu’accéder aux données brutes, vous pouvez vous arrêter là.

 

Dessiner les données de l’accéléromètre

Le composant graphique Rectangle est déjà prêt à être utilisé en tant que pixel, ce qui va nous rendre la tâche plus simple que ce que vous pourriez penser. Un graphe ne sera qu’une suite de Rectangles consciencieusement disposes auxquels on donnera une couleur en utilisant la propriété Fill. Nous « peindrons » ces rectangles sur le Canvas blanc que nous avons mis en place dans la première section. Les propriétés Top et Left du Rectangle détermineront leur position sur le canvas, et nous pouvons même faire de ces valeurs des valeurs relatives au Canvas en référençant le Canvas dans la méthode SetValue du Rectangle.

Sur le graphique, nous allons dessiner trois lignes de couleurs différentes, chacune représentant un axe de l’accéléromètre. Ces lignes seront composées de Rectangle adjacents. Pour chacun de ces rectangles, nous allons utiliser deux valeurs pour déterminer leur position : le temps passé, que nous déterminerons en utilisant un simple accumulateur incrémenté de 1, et bien sur la donnée retournée par l’accéléromètre récupéré par l’objet AccelerometerReadingEventArgs.

L’évènement ReadingChanged va piloter la progression du graphe exactement comme il pilotait la représentation textuelle dans la section précédente. L’évènement ReadingChangedo est uniquement levé lorsque l’état de l’accéléromètre est « started », après l’appel à la méthode Start de l’objet Accelerometer. Ceci se produira en synchronisation avec le cycle natif de rafraichissement de l’application (30 frames par secondes par défaut), et est très fiable tel que va le démonter le graphique.

Comme vous vous en doutez, la création des rectangles doit se faire sur le thread UI, vous devez donc étendre la méthode updateMyScreen. Avant de faire cela, commençons par le plus simple : mettre en place l’accumulateur. Evidemment, cet accumulateur doit être en dehors du scope de la méthode updateMyScreen, nous en ferons donc un membre de classe, tel l’objet Accelerometer :

C#
  1. public partial class MainPage : PhoneApplicationPage
  2. {
  3.     // Constructor
  4.     Accelerometer accelerometer = new Accelerometer();
  5.     double iterator = 0;
  6.     ...
  7. }
Visual Basic
  1. Partial Public Class MainPage
  2.     Inherits PhoneApplicationPage
  3.     ' Constructor
  4.     Private accelerometer As New Accelerometer()
  5.     Private iterator As Double = 0
  6. ...

Notre accumulateur sera de type double pour éviter de gâcher des cycles CPU à convertir plus tard sa valeur en position sur le Canvas. Il s’augmentera toutefois par palier de 1, ce qui est ce que l’on attend de lui. Lorsque sa valeur augmentera de 1, la position horizontale que nous assignerons aux éléments Rectangle sera décalée vers la droite de 1 pixel, permettant ainsi de disposer ces rectangles de gauche à droite à l’écran qui, à terme, dessinera une ligne.

Note

Il est important de noter que nous utiliserons un Canvas de 400x400 pixels.

Mettons maintenant à jour la méthode updateMyScreen pour mettre en place le dessin. Ceci ce fait en plusieurs phases :

1. Convertir les valeurs retournées par l’accéléromètre (qui vont de la valeur la plus faible de -1.0 à un maximum de 1.0 et une valeur moyenne de 0) dans une position verticale qui sera utilisée pour placer le Rectangle sur le Canvas (qui vont de la valeur la plus faible de 400 à un ‘maximum’ de 0 et un milieu de 200). Comme pour l’accumulateur, la valeur retournée sera stockée en tant que double pour pouvoir être utilisé directement par l’objet Canvas.

2. Les éléments Rectangle qui seront utilisés en tant que pixel pour dessiner les lignes doivent être créés, avoir une taille de 1 pixel et être colorés, avant d’être dessinés. Nous avons donc trois nouveaux Rectangles par mise à jour de ReadingChanged, chacun représentant un des trois axes de l’accéléromètre.

3. Les valeurs converties retournées par l’accéléromètre de l’étape 1 sont utilisées pour la position verticale des Rectangle sur le Canvas, et la valeur de l’accumulateur est utilisée pour la position horizontale du Rectangle sur le Canvas. Ils sont passés au Rectangle par la méthode SetValue.

4. Les trois nouveaux Rectangles sont dessinés sur le Canvas en faisant des Rectangles des "enfants" (children) du Canvas. Ceci est accompli par l’utilisation de la méthode Add de la propriété Children du Canvas.

5. Si vous êtes en dehors de l’espace horizontal disponible sur le Canvas (c’est à dire lorsque l’accumulateur a atteint la valeur 399), vous pouvez vider le Canvas et recommencer depuis le côté gauche (en vidant la propriété Children du Canvas et en réinitialisant l’accumulateur à zéro), ou sinon vous incrémentez l’accumulateur de 1.

La fonction updateMyScreen ressemble alors à ceci :

C#
  1. void updateMyScreen(AccelerometerReadingEventArgs e)
  2. {
  3.     // updates the textblocks
  4.     xreadout.Text = e.X.ToString("0.00");
  5.     yreadout.Text = e.Y.ToString("0.00");
  6.     zreadout.Text = e.Z.ToString("0.00");
  7.     //draws on the canvas:
  8.     double currentXOnGraph = Math.Abs((e.X * 200) - 200);
  9.     double currentYOnGraph = Math.Abs((e.Y * 200) - 200);
  10.     double currentZOnGraph = Math.Abs((e.Z * 200) - 200);
  11.     Rectangle xPoint = new Rectangle();
  12.     Rectangle yPoint = new Rectangle();
  13.     Rectangle zPoint = new Rectangle();
  14.     xPoint.Fill = new SolidColorBrush(Colors.Red);
  15.     yPoint.Fill = new SolidColorBrush(Colors.Blue);
  16.     zPoint.Fill = new SolidColorBrush(Colors.Green);
  17.     // set the pixel size
  18.     xPoint.Width = 1;
  19.     xPoint.Height = 2;
  20.     yPoint.Width = 1;
  21.     yPoint.Height = 2;
  22.     zPoint.Width = 1;
  23.     zPoint.Height = 2;
  24.     // These pixels will be "pasted into" the canvas by setting their position
  25.     // according to the currentX/Y/Z-on-graph values. To set their position
  26.     // relative to the canvas, pass the canvas properties on to the pixels via
  27.     // the SetValue method. Use a generic iterator to determine the distance
  28.     // the pixel should be from the left side of the canvas.
  29.     xPoint.SetValue(Canvas.LeftProperty, iterator);
  30.     xPoint.SetValue(Canvas.TopProperty, currentXOnGraph);
  31.     yPoint.SetValue(Canvas.LeftProperty, iterator);
  32.     yPoint.SetValue(Canvas.TopProperty, currentYOnGraph);
  33.     zPoint.SetValue(Canvas.LeftProperty, iterator);
  34.     zPoint.SetValue(Canvas.TopProperty, currentZOnGraph);
  35.     // finally, associate pixels with the canvas.
  36.     Log.Children.Add(xPoint);
  37.     Log.Children.Add(yPoint);
  38.     Log.Children.Add(zPoint);
  39.     if (iterator == 399)
  40.     {
  41.         Log.Children.Clear();
  42.         iterator = 0;
  43.     }
  44.     else
  45.     {
  46.         iterator++;
  47.     }
  48. }
Visual Basic
  1. Private Sub updateMyScreen(e As AccelerometerReadingEventArgs)
  2.     ' updates the textblocks
  3.     xreadout.Text = e.X.ToString("0.00")
  4.     yreadout.Text = e.Y.ToString("0.00")
  5.     zreadout.Text = e.Z.ToString("0.00")
  6.     'draws on the canvas:
  7.     Dim currentXOnGraph As Double = Math.Abs((e.X * 200) - 200)
  8.     Dim currentYOnGraph As Double = Math.Abs((e.Y * 200) - 200)
  9.     Dim currentZOnGraph As Double = Math.Abs((e.Z * 200) - 200)
  10.     Dim xPoint As New Rectangle()
  11.     Dim yPoint As New Rectangle()
  12.     Dim zPoint As New Rectangle()
  13.     xPoint.Fill = New SolidColorBrush(Colors.Red)
  14.     yPoint.Fill = New SolidColorBrush(Colors.Blue)
  15.     zPoint.Fill = New SolidColorBrush(Colors.Green)
  16.     ' set the pixel size
  17.     xPoint.Width = 1
  18.     xPoint.Height = 2
  19.     yPoint.Width = 1
  20.     yPoint.Height = 2
  21.     zPoint.Width = 1
  22.     zPoint.Height = 2
  23.     ' These pixels will be "pasted into" the canvas by setting their position
  24.     ' according to the currentX/Y/Z-on-graph values. To set their position
  25.     ' relative to the canvas, pass the canvas properties on to the pixels via
  26.     ' the SetValue method. Use a generic iterator to determine the distance
  27.     ' the pixel should be from the left side of the canvas.
  28.     xPoint.SetValue(Canvas.LeftProperty, iterator)
  29.     xPoint.SetValue(Canvas.TopProperty, currentXOnGraph)
  30.     yPoint.SetValue(Canvas.LeftProperty, iterator)
  31.     yPoint.SetValue(Canvas.TopProperty, currentYOnGraph)
  32.     zPoint.SetValue(Canvas.LeftProperty, iterator)
  33.     zPoint.SetValue(Canvas.TopProperty, currentZOnGraph)
  34.     ' finally, associate pixels with the canvas.
  35.     Log.Children.Add(xPoint)
  36.     Log.Children.Add(yPoint)
  37.     Log.Children.Add(zPoint)
  38.     If iterator = 399 Then
  39.         Log.Children.Clear()
  40.         iterator = 0
  41.     Else
  42.         iterator += 1
  43.     End If
  44. End Sub

Enfin, pour avoir une chance d’observer les données, et d’en arrêter la visualisation, nous devons écouter l’évènement Play/Pause du bouton. Dans Visual Studio, double-cliquez tout simplement sur le bouton dans le designer et l’évènement OnClick sera lié automatiquement à une nouvelle fonction nommée convenablement. Vous pouvez aussi écrire cela vous-même. Dans tous les cas, comme l’évènement ReadingChanged va mettre à jour le graphe pour nous, la seule chose à faire dans l’évènement Play/Pause et de démarrer ou arrêter l’accéléromètre. La fonction ressemblera à ceci :

C#
  1. private void PlayOrPause_Click(object sender, RoutedEventArgs e)
  2. {
  3.     if (PlayOrPause.Content.ToString() == "PAUSE")
  4.     {
  5.         PlayOrPause.Content = "PLAY";
  6.         accelerometer.Stop();
  7.     }
  8.     else if (PlayOrPause.Content.ToString() == "PLAY")
  9.     {
  10.         accelerometer.Start();
  11.         PlayOrPause.Content = "PAUSE";
  12.     }
  13. }
Visual Basic
  1. Private Sub PlayOrPause_Click(sender As Object, e As RoutedEventArgs)
  2.     If PlayOrPause.Content.ToString() = "PAUSE" Then
  3.         PlayOrPause.Content = "PLAY"
  4.         accelerometer.[Stop]()
  5.     ElseIf PlayOrPause.Content.ToString() = "PLAY" Then
  6.         accelerometer.Start()
  7.         PlayOrPause.Content = "PAUSE"
  8.     End If
  9. End Sub

Exécuter ce code affichera un graphe contenant trois lignes ainsi que l’affichage textuel des données de l’accéléromètre. Bien sûr, pour réellement visualiser les données de l’accéléromètre, vous aurez besoin de déployer cette application sur un téléphone Windows Phone. Il est toutefois possible de simuler l’accéléromètre si vous ne disposez pas d’un téléphone.

A voir

 


Cliquez ici pour revenir au sommaire de la liste d’articles

Skip to main content