Click here to Skip to main content
15,881,938 members
Articles / Desktop Programming / WPF

Extendable Screen Saver with Prism

Rate me:
Please Sign up or sign in to vote.
5.00/5 (2 votes)
28 Jan 2017CPOL9 min read 8.3K   261   4  
Screen saver application written in WPF with Prism pattern
In this article, you will find the complete application with two different animation patterns implemented in two modules. The application is modular and could be easily extended by adding new modules.

Introduction

The post is devoted to screen saver application written in WPF with Prism pattern. The provided code is the complete application with two different animation patterns that are implemented in two modules. The application is modular and could be easily extended by adding new modules.

Features

The application demonstrates the following features:

  1. Can be installed as screen saver
  2. Supports secondary displays
  3. Contains two animation modules: blinking stripes and animated grid
  4. User may change modules on the fly
  5. User may restart the animation and change settings of the modules
  6. Settings are saved in XML files
  7. Logging with log4net library

Background

The solution uses C#6, .NET 4.5.1, WPF with Prism pattern, NuGet packages Extended.Wpf.Toolkit, Ikc5.Prism.Settings and Ikc5.TypeLibrary.

Screen Saver

First module Image 2
Image 3 Image 4
Image 5 Image 6

Screen saver is an ordinary GUI application but with a special way of the launching. It should be able to accept input parameters and to execute in three modes: show, preview and configure.
There are some posts where WPF screen savers are described:

The using of Prism pattern allows to create a modular application where animated modules could be easily added and then switched on the fly. Views in modules implement IActiveAware interface that allow to control activity of the view and execute commands. Modules and the WPF application has settings that include sizes, colors, iteration delays. The application uses Ikc5.Prism.Settings packages, described in Examples of using Ikc5.Prism.Settings, and saves settings of the application and modules in XML files in %AppData% folder.

Solution

The solution has the following structure:

  1. Common - Common.Models contains enumerations, models classes and interfaces; Common.ViewModels contains attached properties, hierarchy of converters, styles and view models
  2. First module - FirstModule.Models contains settings class and model classes; FirstModule.Views contains module class, views and view models
  3. Second module - has the same structure as the first module
  4. ScreenSaver - the main WPF application

Common Class Libraries

Animation modules are based on dynamic grid described in Grid with dynamic number of rows and columns, part 2. That post describes WPF datagrid with cells that have defined fixed size but number of rows and columns is updated dynamically in order to fill all available space. Here and below, we refer to classes from that code.

Common.Models includes ICell interface, Cell and CellSet classes. Cell is not changed and has one Boolean property, and cell set gets additional method InvertPoint for iterations:

C#
public void InvertPoints(IEnumerable<Point> newPoints)
{
    if (newPoints == null)
        return;
    foreach (var point in newPoints)
    {
        Cells[point.X, point.Y].State = !Cells[point.X, point.Y].State;
    }
}

Common.Models includes IDynamicGridViewModel and IBaseCellViewModel interfaces, design and base view models. IBaseCellViewModel is used by a view that shows cell model, and could be inherited and extended in modules by additional properties like colors. It is simple:

C#
public interface IBaseCellViewModel
{
    /// <summary>
    /// Cell model.
    /// </summary>
    ICell Cell { get; set; }
}

IDynamicGridViewModel interface is extended by iterate commands and IActiveAware interface:

C#
public interface IDynamicGridViewModel<TCellViewModel> : 
                         IActiveAware where TCellViewModel : IBaseCellViewModel
{
    /// <summary>
    /// Width of current view - expected to be bound to view's actual
    /// width in OneWay binding.
    /// </summary>
    int ViewWidth { get; set; }

    /// <summary>
    /// Height of current view - expected to be bound to view's actual
    /// height in OneWay binding.
    /// </summary>
    int ViewHeight { get; set; }

    /// <summary>
    /// Width of the cell.
    /// </summary>
    int CellWidth { get; set; }

    /// <summary>
    /// Height of the cell.
    /// </summary>
    int CellHeight { get; set; }

    /// <summary>
    /// Count of grid columns.
    /// </summary>
    int GridWidth { get; }

    /// <summary>
    /// Count of grid rows.
    /// </summary>
    int GridHeight { get; }

    /// <summary>
    /// Data model.
    /// </summary>
    CellSet CellSet { get; }

    /// <summary>
    /// 2-dimensional collections for CellViewModels.
    /// </summary>
    ObservableCollection<ObservableCollection<TCellViewModel>> Cells { get; }

    /// <summary>
    /// Iterates screen saver at one cycle.
    /// </summary>
    ICommand IterateCommand { get; }

    /// <summary>
    /// Command starts iterating.
    /// </summary>
    ICommand StartIteratingCommand { get; }

    /// <summary>
    /// Command stops iterating.
    /// </summary>
    ICommand StopIteratingCommand { get; }

    /// <summary>
    /// Set new initial set of points.
    /// </summary>
    ICommand RestartCommand { get; }
}

Implementation of the interface is an abstract DynamicGridViewModel class. It keeps code from mentioned post, implements IActiveAware and is extended by iteration methods and commands. Implementation and objectives of IActiveAware interface is described in Detecting the Active View in a Prism App.

View model contains iteration timer:

C#
_iterateTimer = new DispatcherTimer
{
    Interval = TimeSpan.FromMilliseconds(IterationDelay),
};
_iterateTimer.Tick += IterateTimerTick;

When timer ticks, IterateTimerTick method is called, where randomly generated set of cells inverts states:

C#
private void IterateTimerTick(object sender, EventArgs e)
{
    Iterate();
}

private void Iterate()
{
    if (CellSet == null)
        return;

    using (Application.Current.Dispatcher.DisableProcessing())
    {
        var points = GenerateRandomPoints(11);
        CellSet.InvertPoints(points);
    }
}

In addition, iteration timer should be paused and then continued if cell set is recreated due to change in view size. Therefore, class contains methods such that StartTimer, StopTimer, PauseIteration and ContinueIteration with natural implementation.

C#
protected void PauseIteration()
{
    if (_iterateTimer.IsEnabled)
    {
        _postponedTimer = true;
        _iterateTimer.Stop();
    }
}

protected void ContinueIteration()
{
    if (_postponedTimer)
        StartTimer();
}

/// <summary>
/// Start the timer for screen saver iterations. But method could have not effect
/// if cell set still waits for all necessary data for creating. Then timer will
/// start after cell set has been created.
/// </summary>
private void StartTimer()
{
    if (CellSet == null || Cells == null)
        _postponedTimer = true;
    else
    {
        _iterateTimer.Start();
        _postponedTimer = false;
        SetCommandProviderMode(CommandProviderMode.Iterating);
    }
}

/// <summary>
/// Stop iteration timer.
/// </summary>
private void StopTimer()
{
    _iterateTimer.Stop();
    SetCommandProviderMode(CommandProviderMode.Init);
}

Modules

The application contains two modules. The first module shows grid with cells in two colors: StartColor if State equals true, and FinishColor otherwise. The second module shows vertical stripes with gradient fill that corresponds to cell models with State equals true. Animations are implemented by CellView classes.

XML
<Grid x:Name="MainPanel">
    <Border
        BorderThickness="1"
        BorderBrush="{Binding BorderColor,
                RelativeSource={RelativeSource FindAncestor, 
                AncestorType={x:Type UserControl}},
                Converter={StaticResource ColorToBrushConverter},
                FallbackValue=#FF000000}"
        Background="{Binding StartColor,
                RelativeSource={RelativeSource FindAncestor, 
                AncestorType={x:Type UserControl}},
                Converter={StaticResource ColorToBrushConverter},
                FallbackValue=#FF40FF40}">

        <Border
            BorderThickness="0"
            Background="{Binding FinishColor,
                            RelativeSource={RelativeSource FindAncestor, 
                            AncestorType={x:Type UserControl}},
                            Converter={StaticResource ColorToBrushConverter},
                            FallbackValue=#FFFF4040}"
            Visibility="{Binding Path=Cell.State, Mode=OneWay, 
                        Converter={StaticResource BooleanToVisibilityConverter}, 
                        FallbackValue=Hidden}"
            attached:VisibilityAnimation.AnimationType=
                        "{Binding Path=Settings.AnimationType, Mode=OneWay}" 
            attached:VisibilityAnimation.AnimationDuration=
                        "{Binding Path=Settings.AnimationDelay, Mode=OneWay}"/>
    </Border>
</Grid>
XML
<Grid x:Name="MainPanel">
    <Border
        BorderThickness="0"
        Background="Transparent">

        <Border
            BorderThickness="1"
            BorderBrush="{Binding BorderColor,
                    RelativeSource={RelativeSource FindAncestor, 
                                    AncestorType={x:Type UserControl}},
                    Converter={StaticResource ColorToBrushConverter},
                    FallbackValue=#FF000000}"
            Visibility="{Binding Path=Cell.State, Mode=OneWay, 
                        Converter={StaticResource BooleanToVisibilityConverter}, 
                                   FallbackValue=Visible}"
            attached:VisibilityAnimation.AnimationType=
                                 "{Binding Path=Settings.AnimationType, Mode=OneWay}" 
            attached:VisibilityAnimation.AnimationDuration=
                                 "{Binding Path=Settings.AnimationDelay, Mode=OneWay}">
                
            <Border.Background>
                <LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
                    <GradientStop Color="{Binding StartColor,
                                        RelativeSource={RelativeSource FindAncestor, 
                                        AncestorType={x:Type UserControl}},
                                        FallbackValue=#FF40FF40}" Offset="0"/>
                    <GradientStop Color="{Binding FinishColor,
                                        RelativeSource={RelativeSource FindAncestor, 
                                        AncestorType={x:Type UserControl}},
                                        FallbackValue=#FFFF4040}" Offset="1"/>
                </LinearGradientBrush>
            </Border.Background>
        </Border>
    </Border>
</Grid>

Modules are constructed in a similar way, so let's consider one of them. FirstModule.Models class library includes ISettings interface that lists settings of the module: colors, sizes and delays, and default implementation of this interface - Settings class. FirstModule.Views contains MainView, SettingsView and CellView views and corresponding view models. MainView code is the same as described in the mentioned post. SettingsView is used in a region of SettingsWindow window of the application and provides to user current settings and possibility to change them.

IMainViewModel and ICellViewModel interfaces are derived from IDynamicGridViewModel and IBaseCellViewModel, respectively. Derived interfaces contains ISettings instance that allows to form presentation based on module's settings.

Further, MainViewModel view model are derived from DynamicGridViewModel and just implement ISettings property and subscriber to PropertyChanged event.

C#
private ISettings _settings;

public ISettings Settings
{
    get { return _settings; }
    private set
    {
        var userSettings = _settings as IUserSettings;
        if (userSettings != null)
            userSettings.PropertyChanged -= UserSettingsOnPropertyChanged;

        SetProperty(ref _settings, value);
        userSettings = _settings as IUserSettings;
        if (userSettings != null)
            userSettings.PropertyChanged += UserSettingsOnPropertyChanged;
    }
}

private void UserSettingsOnPropertyChanged(object sender, PropertyChangedEventArgs args)
{
    if (CellSet == null)
        return;

    PauseIteration();

    switch (args.PropertyName)
    {
    case nameof(Settings.CellWidth):
        CellWidth = Settings.CellWidth;
        break;

    case nameof(Settings.CellHeight):
        CellHeight = Settings.CellHeight;
        break;

    case nameof(Settings.IterationDelay):
        IterationDelay = Settings.IterationDelay;
        break;

    default:
        break;
    }

    ContinueIteration();
}

Interrelation Between Modules

One of the principles of Prism pattern is the independence of modules, but the application needs to interact with modules. In this example, the main window shows context menu that contains "Restart" item. It requires to find active view and to restart its animations. This task is solved by the using CompositeCommand and DelegateCommand.

Module main views implement IActiveAware interface that is supported by Prism infrastructure. Then view passes calls to view model and commands, and view model is aware of the module's activity.

Common library contains ICommandProvider interface:

C#
public interface ICommandProvider : INotifyPropertyChanged
{
    CompositeCommand IterateCommand { get; }
    CompositeCommand StartIteratingCommand { get; }
    CompositeCommand StopIteratingCommand { get; }
    CompositeCommand RestartCommand { get; }
}

In the main application, this interface is implemented by CommandProvider class, where composite commands are created with awareness of active modules:

C#
public class CommandProvider : BindableBase, ICommandProvider
{
    public CompositeCommand IterateCommand { get; }
        = new CompositeCommand(monitorCommandActivity: true);
    public CompositeCommand StartIteratingCommand { get; }
        = new CompositeCommand(monitorCommandActivity: true);
    public CompositeCommand StopIteratingCommand { get; }
        = new CompositeCommand(monitorCommandActivity: true);
    public CompositeCommand RestartCommand { get; }
        = new CompositeCommand(monitorCommandActivity: true);
}

View model of the main view in each module creates delegate commands and registers them:

C#
// create commands
IterateCommand = new DelegateCommand(Iterate, () => CanIterate)
    { IsActive = IsActive };
StartIteratingCommand = new DelegateCommand(StartTimer, () => CanStartIterating)
    { IsActive = IsActive };
StopIteratingCommand = new DelegateCommand(StopTimer, () => CanStopIterating)
    { IsActive = IsActive };
RestartCommand = new DelegateCommand(Restart, () => CanRestart)
    { IsActive = IsActive };

// register command in composite commands
commandProvider.IterateCommand.RegisterCommand(IterateCommand);
commandProvider.StartIteratingCommand.RegisterCommand(StartIteratingCommand);
commandProvider.StopIteratingCommand.RegisterCommand(StopIteratingCommand);
commandProvider.RestartCommand.RegisterCommand(RestartCommand);

CanExecute properties are updated depending on different states of the iteration and the creating models.

Add New Module

As was mentioned above, new modules can be easily added. The application provides to user the list of all registered modules and allows to choose active one. Settings window contains tabs for settings of all registered modules. So let's consider steps to add module named ThirdModule:

  1. Create new class libraries ThirdLibrary.Views and ThirdLibrary.Views or copy SecondModule.* libraries and rename all files and classes from "Second" to "Third".
  2. Update properties in ISettings interface in order to correspond module settings; update all derived classes like Settings, DesignSettings, ISettingsViewModel interface, SettingsViewModel, and update elements and bindings in SettingsView view.
  3. Register module's views in application's regions:
    C#
    public void Initialize()
    {
        _regionManager.RegisterViewWithRegion(PrismNames.MainRegionName, typeof(MainView));
        _regionManager.RegisterViewWithRegion($"{GetType().Name}
                       {RegionNames.ModuleSettingsRegion}", typeof(SettingsView));
    }
  4. Register module in Bootstrapper class:
    C#
    protected override void ConfigureModuleCatalog()
    {
        var catalog = (ModuleCatalog)ModuleCatalog;
        // add all modules
        catalog.AddModule(typeof(FirstModule.FirstModule));
        catalog.AddModule(typeof(SecondModule.SecondModule));
        catalog.AddModule(typeof(ThirdModule.ThirdModule));
    }
  5. If module animation is based on dynamic grid, there is enough to update CellView view; otherwise it is necessary to write code for MainView and CellView views.

Screen Saver Application

WPF application contains views, view models, bootstrapper and application classes. MainWindow and EmptyWindow are primary windows for screen saver. Settings class and SettingsWindow is described in Examples of using Ikc5.Prism.Settings. The main window has the main region that occupies the entire space and context menu.

XML
<Grid x:Name="MainGrid"
        d:DataContext="{d:DesignInstance Type=viewModels:DesignMainWindowModel, 
                        IsDesignTimeCreatable=True}">
    <Grid.Background>
        <SolidColorBrush Color="{Binding Path=Settings.BackgroundColor, 
                                 Mode=OneWay, FallbackValue=#FFC0C0C0}"/>
    </Grid.Background>

    <Grid.ContextMenu>
        <ContextMenu> 
            <MenuItem Header="Restart"
                        Command="{Binding RestartCommand, Mode=OneWay}"
                        Click="MenuItem_OnClick"/>
            <Separator />
            <MenuItem Header="Settings"
                        Command="{Binding SettingsCommand, Mode=OneWay}"
                        Click="MenuItem_OnClick"/>
            <MenuItem Header="About"
                        Command="{Binding AboutCommand, Mode=OneWay}"
                        Click="MenuItem_OnClick"/>
        </ContextMenu>
    </Grid.ContextMenu>

    <ContentControl
        regions:RegionManager.RegionName="MainRegion"/>
</Grid>

Context menu:

  1. Restart - restart current view with random set of active cells
  2. Settings - show settings window and allow user change settings without stopping screen saver
  3. About - show about dialog

According to Prism pattern, the application uses Bootstrapper class, where all necessary initialization is executed, and OnStartup method usually looks in the following way:

C#
protected override void OnStartup(StartupEventArgs e)
{
    base.OnStartup(e);
    // create and launch bootstrapper
    var bootstrapper = new Bootstrapper();
    bootstrapper.Run();
}

For screen saver application, the following steps should be done in OnStartup method:

  1. Create bootstrapper, but don't show the main window
  2. Set Shutdown mode according to launch type of screen saver
  3. Parse input arguments and choose the necessary behavior of the application
  4. In the show mode, create additional windows for secondary monitors
  5. In the setting mode, show setting window and correct shutdown mode
  6. In the preview mode, show WPF window in Win32 window and take care of resource clean-up

Below, we consider implementation of these steps.

Initialization

Main window is called Shell in Prism, and usually created in CreateShell method and initialized in InitializeShell method:

C#
protected override DependencyObject CreateShell()
{
    Window mainWindow = Container.Resolve<MainWindow>();
    return mainWindow;
}

protected override void InitializeShell()
{
    var regionManager = Container.Resolve<IRegionManager>();
    // add some views to region adapter
    // ...

    // show window
    Application.Current.MainWindow.Show();
}

In the screen saver, the main window should be completely created, but stays hidden as screen saver could be launched in preview or configure mode. Therefore, InitializeShell doesn't show the main window:

C#
protected override void InitializeShell()
{
    var regionManager = Container.Resolve<IRegionManager>();
    // add some views to region adapter
    // ...

    // don't show window now - application may runs in settings mode
    // Application.Current.MainWindow.Show();
}

If application runs in show mode, it just shows main window in OnStartup mode:

C#
Application.Current.MainWindow.Show();

Input Arguments

There are the following command-line parameters all screen savers need to handle:

  1. /s – show the screensaver
  2. /p – preview the screensaver
  3. /c – configure the screensaver

In addition, arguments could be separated by colon, for examples: /c:1234567 or /P:1234567. The application uses the following enumeration for input arguments:

C#
public enum LaunchType
{
    [Description("No parameters")]
    Default = 0,

    [Description("\\s, Show the screen saver")]
    Show,

    [Description("\\c, Configure settings")]
    Configure,

    [Description("\\p, Show in preview mode")]
    Preview
}

Input arguments are lowered, split by colon, and compare with expected strings. As results, two variables are set: launch type and window descriptor, that is used in preview mode.

C#
var launchType = LaunchType.Default;
var previewWindowDescriptor = 0;

logger.Log($"Start parameters: {string.Join("; ", e.Args)}", Category.Info);
if (e.Args.Length > 0)
{
    var firstArgument = e.Args[0].ToLower().Trim();
    string secondArgument = null;

    // Handle cases where arguments are separated by colon.
    // Examples: /c:1234567 or /P:1234567
    if (firstArgument.Length > 2)
    {
        secondArgument = firstArgument.Substring(3).Trim();
        firstArgument = firstArgument.Substring(0, 2);
    }
    else if (e.Args.Length > 1)
        secondArgument = e.Args[1];

    if (string.Equals("/c", firstArgument))
        launchType = LaunchType.Configure;
    else if (string.Equals("/s", firstArgument))
        launchType = LaunchType.Show;
    else if (string.Equals("/p", firstArgument))
        launchType = LaunchType.Preview;

    if (!string.IsNullOrEmpty(secondArgument))
        previewWindowDescriptor = Convert.ToInt32(secondArgument);
}
logger.Log($"Converted start parameters: launchType={launchType}, 
           previewWindowDescriptor={previewWindowDescriptor}");

Then, the switch is used for providing different behavior depending on launch type.

Show the Screen Saver

As main window is already created, it could be shown. But there is an issue if computer has several displays. By default, operation system blacks all other displays except primary, so it is enough to show main window on primary screen. Depending on application settings, user would like to show screen saver on all displays. The post WPF windows on two screens shows how to position WPF window on secondary monitor or show two windows on two monitors.

Therefore, the application shows main window at primary display, for all secondary displays creates and positions EmptyWindow or new instance of MainWindow. Then Shutdown mode is set to default value:

C#
Current.ShutdownMode = ShutdownMode.OnMainWindowClose;

Configure the Screensaver

In this mode, application shows settings window. As the application uses Ikc5.Prism.Settings packages, it contains SettingsWindow that allows user to set settings and options of the application. But the main window won't be shown and should be closed without shutdown of the application. On the other hand, if the main window will not be closed, application continues to execute in the background. That is why the application shows SettingsWindow, sets shutdown mode to ShutdownMode.OnLastWindowClose and then closes the main window. When user closes settings window, it is considered as the last window in application, and application exits.

C#
var settingsWindow = bootstrapper.Container.Resolve<SettingsWindow>();
settingsWindow.Show();

Current.ShutdownMode = ShutdownMode.OnLastWindowClose;
Current.MainWindow.Close();

Preview the Screen Saver

In this mode, screen saver is displayed in small window. This mode requires that the application should adapt to small size of screen, and WPF window is shown in Win32 window. Another issue is that it is necessary to catch event when parent window is disposed, and close the application. Otherwise, the current instance of the application continues to execute in background. Necessary code is covered by the above mentioned posts about screen saver in WPF, so there is a slightly brushed code:

C#
var mainWindow = Current.MainWindow as MainWindow;
if (mainWindow == null)
{
    Current.Shutdown();
    return;
}

logger.Log("Init objects for preview mode");
var pPreviewHandle = new IntPtr(previewWindowDescriptor);
var lpRect = new RECT();
var bGetRect = Win32API.GetClientRect(pPreviewHandle, ref lpRect);

var sourceParams = new HwndSourceParameters("sourceParams")
{
    PositionX = 0,
    PositionY = 0,
    Width = lpRect.Right - lpRect.Left,
    Height = lpRect.Bottom - lpRect.Top,
    ParentWindow = pPreviewHandle,
    WindowStyle = (int)(WindowStyles.WS_VISIBLE | 
                        WindowStyles.WS_CHILD | WindowStyles.WS_CLIPCHILDREN)
};

logger.Log($"Source param size = ({0}, {0}, {lpRect.Right - lpRect.Left}, 
                                 {lpRect.Bottom - lpRect.Top})");
_winWpfContent = new HwndSource(sourceParams)
{
    RootVisual = mainWindow.MainGrid
};

// Event that triggers when parent window is disposed - used when doing
// screen saver preview, so that we know when to exit. If we didn't
// do this, Task Manager would get a new .scr instance every time
// we opened Screen Saver dialog or switched dropdown to this saver.
_winWpfContent.Disposed += (o, args) =>
{
    logger.Log("_winWpfContent is Disposed, close main window and application");
    mainWindow.Close();
    Current.Shutdown();
};
logger.Log(
    $"MainWindow is shown in preview, IsVisible={mainWindow.IsVisible}, 
              IsActive={mainWindow.IsActive}, Owner={mainWindow.Owner?.Title}" +
    $", Rect=({mainWindow.Left}, {mainWindow.Top}, 
              {mainWindow.Width}, {mainWindow.Height})");

Install the Screen Saver

The solution contains Publish configuration, that renames executable file to Ikc5.ScreenSaver.scr in post-build steps:

ms-dos
if $(ConfigurationName) NEQ Publish Exit 0

cd "$(TargetDir)"
del "$(TargetName).scr"
del "$(TargetName).scr.config"
ren "$(TargetFileName)" "$(TargetName).scr"
ren "$(TargetFileName).config" "$(TargetName).scr.config"

In order to install screen saver, it is necessary to call context menu for Ikc5 ScreenSaver.scr file in File Explorer, and then click on Install item. Screen Saver Window allows to set timeout, settings of screen saver, and preview it. Images below show these steps.

Image 7 Image 8

History

  • 28th January, 2017 - Initial post
  • 29th January, 2017 - Updated zip files (includes fixes from GIT repository)

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Software Developer (Senior)
Ukraine Ukraine
• Have more than 25 years of the architecting, implementing, and supporting various applications from small desktop and web utilities up to full-fledged cloud SaaS systems using mainly Microsoft technology stack and implementing the best practices.
• Have significant experience in the architecting applications starting from the scratch and from the existent application (aka “legacy”) where it is required to review, refactor, optimise the codebase and data structure, migrate to new technologies, implement new features, best practices, create tests and write documentation.
• Have experience in project management, collecting business requirements, creating MVP, working with stakeholders and end users, and tasks and backlog management.
• Have hands-on experience in the setting up CI/CD pipelines, the deploying on-premise and cloud systems both in Azure and AWS, support several environments.
• As Mathematician, I interested much in the theory of automata and computer algebra.

Comments and Discussions

 
-- There are no messages in this forum --