Galacticlip

Cross-platform .NET MAUI application for searching, browsing and bookmarking NASA image-library material.

C# · .NET MAUI · XAML · MVVM · NASA Images API

README.md
# Galacticlip

Galacticlip is a mobile application built with **C# and .NET MAUI** that integrates with NASA's public APIs to present space-related imagery and information in a mobile-friendly interface.

## Overview

The project explores API integration and cross-platform mobile development using .NET MAUI. It retrieves data from NASA services and presents it through a simple native application interface.

## Technologies

* C#
* .NET MAUI
* XAML
* NASA APIs
* REST API integration
* JSON data handling

## Features

* Retrieves space-related content from NASA APIs
* Displays imagery and associated information
* Mobile-focused interface
* Asynchronous API requests
* Cross-platform application structure using .NET MAUI

## Running the Project

Clone the repository and open the solution in a development environment with the appropriate **.NET MAUI workload** installed.

Depending on the NASA API endpoint used, an API key may be required. NASA provides API keys through its developer portal.

The application can then be run using a supported emulator, simulator, or physical device.

## Notes

Images and data retrieved through NASA services remain subject to NASA's applicable usage and attribution policies.

galacticlip_app/galacticlip_app.csproj
<Project Sdk="Microsoft.NET.Sdk">

	<PropertyGroup>
		<TargetFrameworks>net8.0-android;net8.0-ios;net8.0-maccatalyst</TargetFrameworks>
		<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net8.0-windows10.0.19041.0</TargetFrameworks>
		<!-- Uncomment to also build the tizen app. You will need to install tizen by following this: https://github.com/Samsung/Tizen.NET -->
		<!-- <TargetFrameworks>$(TargetFrameworks);net8.0-tizen</TargetFrameworks> -->

		<!-- Note for MacCatalyst:
		The default runtime is maccatalyst-x64, except in Release config, in which case the default is maccatalyst-x64;maccatalyst-arm64.
		When specifying both architectures, use the plural <RuntimeIdentifiers> instead of the singular <RuntimeIdentifier>.
		The Mac App Store will NOT accept apps with ONLY maccatalyst-arm64 indicated;
		either BOTH runtimes must be indicated or ONLY macatalyst-x64. -->
		<!-- For example: <RuntimeIdentifiers>maccatalyst-x64;maccatalyst-arm64</RuntimeIdentifiers> -->

		<OutputType>Exe</OutputType>
		<RootNamespace>galacticlip_app</RootNamespace>
		<UseMaui>true</UseMaui>
		<SingleProject>true</SingleProject>
		<ImplicitUsings>enable</ImplicitUsings>
		<Nullable>enable</Nullable>

		<!-- Display name -->
		<ApplicationTitle>galacticlip_app</ApplicationTitle>

		<!-- App Identifier -->
		<ApplicationId>com.companyname.galacticlip_app</ApplicationId>

		<!-- Versions -->
		<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
		<ApplicationVersion>1</ApplicationVersion>

		<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">11.0</SupportedOSPlatformVersion>
		<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">13.1</SupportedOSPlatformVersion>
		<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">21.0</SupportedOSPlatformVersion>
		<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</SupportedOSPlatformVersion>
		<TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</TargetPlatformMinVersion>
		<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'tizen'">6.5</SupportedOSPlatformVersion>
	</PropertyGroup>

	<ItemGroup>
		<!-- App Icon -->
		<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#512BD4" />

		<!-- Splash Screen -->
		<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#512BD4" BaseSize="128,128" />

		<!-- Images -->
		<MauiImage Include="Resources\Images\*" />
		<MauiImage Update="Resources\Images\dotnet_bot.png" Resize="True" BaseSize="300,185" />

		<!-- Custom Fonts -->
		<MauiFont Include="Resources\Fonts\*" />

		<!-- Raw Assets (also remove the "Resources\Raw" prefix) -->
		<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
	</ItemGroup>

	<ItemGroup>
	  <None Remove="Resources\Fonts\DotoVariableFont.ttf" />
	  <None Remove="Resources\Fonts\PlayfairDisplay-VariableFont_wght.ttf" />
	  <None Remove="Resources\Fonts\Roboto-Regular.ttf" />
	  <None Remove="Resources\Images\bookmark_false_icon.png" />
	  <None Remove="Resources\Images\bookmark_true_icon.png" />
	  <None Remove="Resources\Images\default_background.png" />
	  <None Remove="Resources\Images\delete_icon.png" />
	  <None Remove="Resources\Images\galaxy_background.png" />
	  <None Remove="Resources\Images\icon.png" />
	  <None Remove="Resources\Images\profile1.png" />
	  <None Remove="Resources\Images\profile2.png" />
	  <None Remove="Resources\Images\profile3.png" />
	  <None Remove="Resources\Images\space_background.png" />
	</ItemGroup>

	<ItemGroup>
		<PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" />
		<PackageReference Include="Microsoft.Maui.Controls.Compatibility" Version="$(MauiVersion)" />
		<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="8.0.0" />
		<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
		<PackageReference Include="Plugin.MediaManager" Version="1.2.2" />
	</ItemGroup>

	<ItemGroup>
	  <MauiXaml Update="BookmarkDetailPage.xaml">
	    <Generator>MSBuild:Compile</Generator>
	  </MauiXaml>
	  <MauiXaml Update="Bookmarks.xaml">
	    <Generator>MSBuild:Compile</Generator>
	  </MauiXaml>
	  <MauiXaml Update="MainPage.xaml">
	    <Generator>MSBuild:Compile</Generator>
	  </MauiXaml>
	  <MauiXaml Update="MediaDetailPage.xaml">
	    <Generator>MSBuild:Compile</Generator>
	  </MauiXaml>
	  <MauiXaml Update="Resources\Styles\Colors.xaml">
	    <Generator>MSBuild:Compile</Generator>
	  </MauiXaml>
	  <MauiXaml Update="Resources\Styles\Styles.xaml">
	    <Generator>MSBuild:Compile</Generator>
	  </MauiXaml>
	  <MauiXaml Update="Search.xaml">
	    <Generator>MSBuild:Compile</Generator>
	  </MauiXaml>
	  <MauiXaml Update="Settings.xaml">
	    <Generator>MSBuild:Compile</Generator>
	  </MauiXaml>
	</ItemGroup>

	<ProjectExtensions><VisualStudio><UserProperties XamarinHotReloadDebuggerTimeoutExceptiongalacticlip_appHideInfoBar="True" /></VisualStudio></ProjectExtensions>

</Project>
galacticlip_app/MauiProgram.cs
using Microsoft.Extensions.Logging;

namespace galacticlip_app
{
    public static class MauiProgram
    {
        public static MauiApp CreateMauiApp()
        {
            var builder = MauiApp.CreateBuilder();
            builder
                .UseMauiApp<App>()
                .ConfigureFonts(fonts =>
                {
                    fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
                    fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
                    fonts.AddFont("Roboto-Regular.ttf", "Roboto");
                    fonts.AddFont("PlayfairDisplay-VariableFont_wght.ttf", "Playfair");
                    fonts.AddFont("DotoVariableFont.ttf", "Doto");
                });

#if DEBUG
            builder.Logging.AddDebug();
#endif

            return builder.Build();
        }
    }
}
galacticlip_app/AppShell.xaml
<?xml version="1.0" encoding="UTF-8" ?>
<Shell
    x:Class="galacticlip_app.AppShell"
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    xmlns:local="clr-namespace:galacticlip_app"
    Shell.FlyoutBehavior="Disabled"
    Title="galacticlip_app">

    <TabBar>
        <Tab Title="Home" Icon="home_icon.png" >
            <ShellContent
                Title="Home"
                ContentTemplate="{DataTemplate local:MainPage}"
                Route="MainPage" />
        </Tab>
        <Tab Title="Search" Icon="search_icon.png" >
            <ShellContent
                Title="Search"
                ContentTemplate="{DataTemplate local:Search}"
                Route="Search" />
        </Tab>
        <Tab Title="Bookmarks" Icon="bookmarks_icon.png" >
            <ShellContent
                Title="Bookmarks"
                ContentTemplate="{DataTemplate local:Bookmarks}"
                Route="Bookmarks" /> 
        </Tab>
        <Tab Title="Settings" Icon="settings_icon.png" >
            <ShellContent
                Title="Settings"
                ContentTemplate="{DataTemplate local:Settings}"
                Route="Settings" /> 
        </Tab>
    </TabBar>
    

</Shell>
galacticlip_app/Models/NasaApiResponse.cs
public class NasaApiResponse
{
    public Collection Collection { get; set; }
}

public class Collection
{
    public List<Item> Items { get; set; }
}

public class Item
{
    public List<Data> Data { get; set; }
    public List<Link> Links { get; set; }
}

public class Data
{
    public string Title { get; set; }
    public string Description { get; set; }
    public DateTime DateCreated { get; set; }
    public string Photographer { get; set; }
    public List<string> Keywords { get; set; }
}

public class Link
{
    public string Href { get; set; }
}
galacticlip_app/Models/NasaMediaItem.cs
using MediaManager.Library;
using Newtonsoft.Json;
using System;
using System.ComponentModel;
using System.Runtime.CompilerServices;

namespace galacticlip_app.Models
{
    public class NasaMediaItem : INotifyPropertyChanged
    {
        private bool _isBookmarked;

        public string Id { get; set; }
        public string ThumbnailUrl { get; set; }
        public string Title { get; set; }
        public string Description { get; set; }
        public DateTime DateCreated { get; set; }
        public string Photographer { get; set; }
        public string Keywords { get; set; }
        public string PersonalNote { get; set; }

        public bool IsBookmarked
        {
            get => _isBookmarked;
            set
            {
                _isBookmarked = value;
                OnPropertyChanged();
            }
        }

        public static NasaMediaItem FromMediaItem(MediaItem mediaItem)
        {
            return new NasaMediaItem
            {
                ThumbnailUrl = mediaItem.ImageUri ?? "default-thumbnail-url.jpg",
                Title = mediaItem.DisplayTitle ?? "Untitled",
                Description = mediaItem.DisplayDescription ?? "No description available.",
                DateCreated = mediaItem.Date,
                Photographer = mediaItem.Artist ?? "Unknown photographer",
                Keywords = string.Empty,
                PersonalNote = ""
            };
        }

        public event PropertyChangedEventHandler PropertyChanged;

        protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
        {
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        }
    }
}
galacticlip_app/Services/NasaMediaService.cs
using System.Net.Http;
using System.Threading.Tasks;
using Newtonsoft.Json;
using System.Collections.Generic;
using galacticlip_app.Models;

namespace galacticlip_app.Services
{
    public class NasaMediaService
    {
        private readonly HttpClient _httpClient;
        private const string apiKey = "<REDACTED_LEGACY_API_KEY>"; // Optional for this API, increase rate limits

        public NasaMediaService()
        {
            _httpClient = new HttpClient { BaseAddress = new Uri("https://images-api.nasa.gov/") };
        }

        // Generate random keywords to search for images: too boring before with just one | CAN BE EXPANDED!!!!!!!@!@!@!@iuer4ytavb987y3r

        private static readonly List<string> RandomKeywords = new List<string>
        {
            "nebula", "galaxy", "star", "moon", "planet", "exploration", "astronomy", "rocket", "cosmos", "telescope",
            "asteroid", "comet", "black hole", "solar system", "exoplanet", "orbit", "spacecraft", "lunar", "eclipse",
            "milky way", "mars", "jupiter", "saturn", "venus", "mercury", "uranus", "neptune", "pluto", "earth",
            "satellite", "iss", "space station", "voyager", "apollo", "artemis", "hubble", "perseverance", "curiosity",
            "rover", "landing", "launch", "space shuttle", "spacewalk", "gravity", "cosmology", "interstellar",
            "pulsar", "supernova", "quasar", "big bang", "nasa", "observatory", "deep space", "light-year", "constellation"
        };

        private static readonly Random _random = new Random();

        public async Task<List<NasaMediaItem>> GetRandomImagesAsync()
        {
            var randomKeyword = RandomKeywords[_random.Next(RandomKeywords.Count)];
            return await SearchMediaAsync(randomKeyword);
        }


        public async Task<List<NasaMediaItem>> SearchMediaAsync(string query)
        {
            var requestUrl = $"https://images-api.nasa.gov/search?q={Uri.EscapeDataString(query)}&media_type=image";

            try
            {
                Console.WriteLine("Starting API Request"); // Debug statement
                var response = await _httpClient.GetStringAsync(requestUrl);
                Console.WriteLine("API Request Successful"); // Debug statement

                var nasaApiResponse = JsonConvert.DeserializeObject<NasaApiResponse>(response);
                Console.WriteLine("API Response Deserialized"); // Debug statement

                var mediaItems = new List<NasaMediaItem>();
                foreach (var item in nasaApiResponse.Collection.Items)
                {
                    if (item.Links != null && item.Links.Count > 0 && item.Data != null && item.Data.Count > 0)
                    {
                        mediaItems.Add(new NasaMediaItem
                        {
                            ThumbnailUrl = item.Links[0].Href,
                            Title = item.Data[0].Title,
                            Description = item.Data[0].Description,
                            DateCreated = item.Data[0].DateCreated,
                            Photographer = item.Data[0].Photographer,
                            Keywords = item.Data[0].Keywords != null ? string.Join(", ", item.Data[0].Keywords) : string.Empty
                        });
                    }
                }

                Console.WriteLine($"Images Retrieved: {mediaItems.Count}");
                return mediaItems.Count > 10 ? mediaItems.GetRange(0, 10) : mediaItems;
            }
            catch (Exception ex)
            {
                Console.WriteLine($"Error fetching images: {ex.Message}");
                return new List<NasaMediaItem>();
            }
        }
    }
}
galacticlip_app/MainPage.xaml
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:local="clr-namespace:galacticlip_app"
             x:Class="galacticlip_app.MainPage"
             x:Name="MainPageRoot"
             Title=""
             NavigationPage.HasNavigationBar="False"
             BackgroundColor="{DynamicResource PageBackgroundColor}">

    <!-- Single Root Layout - Grid -->
    <Grid RowDefinitions="Auto, *">

        <!-- Top bar with user photo, user name, and app name -->
        <Grid BackgroundColor="{DynamicResource PageBackgroundColor}" Padding="10">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="Auto" />
                <ColumnDefinition Width="*" />
                <ColumnDefinition Width="Auto" />
            </Grid.ColumnDefinitions>

            <!-- User Photo -->
            <Image Source="{Binding UserProfilePhoto}" 
                   HeightRequest="40" 
                   WidthRequest="40" 
                   Aspect="AspectFill" 
                   HorizontalOptions="Start" 
                   VerticalOptions="Center">
                <Image.Clip>
                    <EllipseGeometry Center="20,20" RadiusX="20" RadiusY="20" />
                </Image.Clip>
            </Image>

            <!-- User Name Display -->
            <Label Text="{Binding UserName}" 
                   FontSize="{DynamicResource MediumFontSize}" 
                   VerticalOptions="Center" 
                   HorizontalOptions="Start"
                   Grid.Column="1" 
                   Margin="10,0,0,0"
                   TextColor="{DynamicResource TextColor}" />

            <!-- App Name Display on the Right -->
            <Label Text="Galacticlip" 
                   FontSize="{DynamicResource MediumFontSize}" 
                   FontFamily="{DynamicResource AppFontFamily}"
                   FontAttributes="Bold" 
                   HorizontalOptions="End" 
                   Grid.Column="2"
                   TextColor="{DynamicResource TextColor}" />
        </Grid>

        <!-- Main Content - NASA Gallery within ScrollView -->
        <ScrollView Grid.Row="1">
            <VerticalStackLayout Padding="10">

                <!-- Collection View for NASA media items -->
                <CollectionView ItemsSource="{Binding Images}">
                    <CollectionView.ItemTemplate>
                        <DataTemplate>
                            <Frame Padding="5" Margin="5" BackgroundColor="{DynamicResource CardBackgroundColor}" BorderColor="{DynamicResource AccentColor}" CornerRadius="8">
                                <VerticalStackLayout>
                                    <!-- Image with Bookmark Icon -->
                                    <Grid>
                                        <!-- Main Image -->
                                        <Image Source="{Binding ThumbnailUrl}" 
                                               Aspect="AspectFill" 
                                               HeightRequest="200" />

                                        <!-- Bookmark Icon positioned in top-right corner -->
                                        <Frame BackgroundColor="{DynamicResource CardBackgroundColor}"
                                               CornerRadius="12"
                                               Padding="0"
                                               WidthRequest="32" HeightRequest="32"
                                               HorizontalOptions="End"
                                               VerticalOptions="Start"
                                               Margin="5"
                                               HasShadow="False">
                                            <ImageButton BackgroundColor="Transparent"
                                                         WidthRequest="24" HeightRequest="24"
                                                         Command="{Binding BindingContext.BookmarkCommand, Source={x:Reference MainPageRoot}}"
                                                         CommandParameter="{Binding .}"
                                                         Padding="4">
                                                <ImageButton.Source>
                                                    <Binding Path="IsBookmarked" Converter="{StaticResource BoolToImageConverter}" />
                                                </ImageButton.Source>
                                            </ImageButton>
                                        </Frame>

                                    </Grid>

                                    <!-- Details Section Below Image -->
                                    <VerticalStackLayout Padding="5" BackgroundColor="{DynamicResource SecondaryColor}">
                                        <Label Text="{Binding Title}" 
                                               FontAttributes="Bold" 
                                               FontSize="{DynamicResource MediumFontSize}" 
                                               FontFamily="{DynamicResource AppFontFamily}"
                                               TextColor="{DynamicResource TextColor}" />
                                        <Label Text="{Binding Description}" 
                                               FontSize="{DynamicResource MediumFontSize}" 
                                               FontFamily="{DynamicResource AppFontFamily}"
                                               TextColor="{DynamicResource TextColor}" 
                                               LineBreakMode="TailTruncation" 
                                               MaxLines="2" />
                                    </VerticalStackLayout>
                                </VerticalStackLayout>

                                <Frame.GestureRecognizers>
                                    <TapGestureRecognizer 
                                        Command="{Binding Source={x:Reference MainPageRoot}, Path=BindingContext.ShowDetailsCommand}" 
                                        CommandParameter="{Binding}" />
                                </Frame.GestureRecognizers>
                            </Frame>
                        </DataTemplate>
                    </CollectionView.ItemTemplate>
                </CollectionView>

            </VerticalStackLayout>
        </ScrollView>

    </Grid>

</ContentPage>
galacticlip_app/MainPageViewModel.cs
using System.Collections.ObjectModel;
using galacticlip_app.Models;
using galacticlip_app.Services;
using System.Threading.Tasks;
using System.Linq;
using Newtonsoft.Json;
using System.Windows.Input;

namespace galacticlip_app
{
    public class MainPageViewModel : BindableObject
    {
        private readonly NasaMediaService _nasaMediaService;
        public ObservableCollection<NasaMediaItem> Images { get; private set; }

        private string _userName;
        public string UserName
        {
            get => _userName;
            set
            {
                _userName = value;
                OnPropertyChanged();
            }
        }

        private string _userProfilePhoto;
        public string UserProfilePhoto
        {
            get => _userProfilePhoto;
            set
            {
                _userProfilePhoto = value;
                OnPropertyChanged();
            }
        }


        public ICommand BookmarkCommand { get; }
        public ICommand ShowDetailsCommand { get; }

        public MainPageViewModel()
        {
            _nasaMediaService = new NasaMediaService();
            Images = new ObservableCollection<NasaMediaItem>();

            UserName = Preferences.Get("UserName", "Guest");
            UserProfilePhoto = Preferences.Get("UserProfilePhoto", "Star Explorer");
            LoadRandomImages();
            BookmarkCommand = new Command<NasaMediaItem>(OnBookmarkItem);

            // Subscribe to bookmark updates
            AppMessenger.MessageReceived += OnMessageReceived;

            ShowDetailsCommand = new Command<NasaMediaItem>(OnShowDetails);
        }

        private async void LoadRandomImages()
        {
            var images = await _nasaMediaService.GetRandomImagesAsync();
            foreach (var image in images)
            {
                Images.Add(image);
            }

            RefreshBookmarkStates();
            OnPropertyChanged(nameof(Images));
        }

        private void OnBookmarkItem(NasaMediaItem item)
        {
            item.IsBookmarked = !item.IsBookmarked;

            var currentBookmarksJson = Preferences.Get("BookmarkedItems", "[]");
            var currentBookmarks = JsonConvert.DeserializeObject<List<NasaMediaItem>>(currentBookmarksJson) ?? new List<NasaMediaItem>();

            if (item.IsBookmarked)
            {
                currentBookmarks.Add(item);
            }
            else
            {
                currentBookmarks.RemoveAll(b => b.ThumbnailUrl == item.ThumbnailUrl);
            }

            var updatedBookmarksJson = JsonConvert.SerializeObject(currentBookmarks);
            Preferences.Set("BookmarkedItems", updatedBookmarksJson);

            // Notify other parts of the app
            AppMessenger.SendMessage("BookmarksUpdated");
        }

        private void RefreshBookmarkStates()
        {
            var bookmarkedJson = Preferences.Get("BookmarkedItems", string.Empty);
            var bookmarkedItems = string.IsNullOrWhiteSpace(bookmarkedJson)
                ? new List<NasaMediaItem>()
                : JsonConvert.DeserializeObject<List<NasaMediaItem>>(bookmarkedJson);

            foreach (var image in Images)
            {
                image.IsBookmarked = bookmarkedItems.Any(b => b.ThumbnailUrl == image.ThumbnailUrl);
            }

            OnPropertyChanged(nameof(Images));
        }

        private void OnMessageReceived(string message)
        {
            if (message == "BookmarksUpdated")
            {
                RefreshBookmarkStates();
            }
        }

        ~MainPageViewModel()
        {
            AppMessenger.MessageReceived -= OnMessageReceived;
        }

        private async void OnShowDetails(NasaMediaItem item)
        {
            if (item != null)
            {
                // Navigate to MediaDetailPage with the selected item
                await App.Current.MainPage.Navigation.PushModalAsync(new MediaDetailPage(item));
            }

           
        }
    }
}
galacticlip_app/Search.xaml
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="galacticlip_app.Search"
             x:Name="SearchPageRoot"
             Title=""
             NavigationPage.HasNavigationBar="False"
             BackgroundColor="{DynamicResource PageBackgroundColor}">

    <!-- Layout: Search Bar and Media Display -->
    <Grid RowDefinitions="Auto, *" Padding="0">

        <!-- Search Bar with Filter Menu -->
        <Grid Grid.Row="0" ColumnDefinitions="*, Auto" Margin="0,10,0,10">
            <SearchBar x:Name="SearchBar" 
                       Placeholder="Search for media..." 
                       FontSize="{DynamicResource MediumFontSize}" 
                       FontFamily="{DynamicResource AppFontFamily}"
                       BackgroundColor="{DynamicResource CardBackgroundColor}"
                       TextColor="{DynamicResource TextColor}"
                       PlaceholderColor="{DynamicResource PlaceholderColor}"
                       Text="{Binding SearchQuery}" 
                       SearchCommand="{Binding ExecuteSearchCommand}" />

        </Grid>

        <!-- Media Display in Two Columns -->
        <ScrollView Grid.Row="1">
            <Grid ColumnDefinitions="*, *" Padding="0" BackgroundColor="Transparent">
                <!-- Left Column -->
                <VerticalStackLayout Grid.Column="0" Spacing="10" Padding="0">
                    <CollectionView ItemsSource="{Binding LeftColumnImages}" 
                                    ItemsLayout="VerticalList">
                        <CollectionView.ItemTemplate>
                            <DataTemplate>
                                <Frame Padding="0" Margin="5" HasShadow="False" BackgroundColor="{DynamicResource CardBackgroundColor}" BorderColor="{DynamicResource AccentColor}" CornerRadius="8">
                                    <Image Source="{Binding ThumbnailUrl}" 
                                           Aspect="AspectFill" 
                                           HeightRequest="200" 
                                           WidthRequest="150">
                                        <Image.GestureRecognizers>
                                            <TapGestureRecognizer Command="{Binding BindingContext.OpenMediaDetailCommand, Source={x:Reference SearchPageRoot}}" 
                                                                  CommandParameter="{Binding .}" />
                                        </Image.GestureRecognizers>
                                    </Image>
                                </Frame>
                            </DataTemplate>
                        </CollectionView.ItemTemplate>
                    </CollectionView>
                </VerticalStackLayout>

                <!-- Right Column -->
                <VerticalStackLayout Grid.Column="1" Spacing="10" Padding="0">
                    <CollectionView ItemsSource="{Binding RightColumnImages}" 
                                    ItemsLayout="VerticalList">
                        <CollectionView.ItemTemplate>
                            <DataTemplate>
                                <Frame Padding="0" Margin="5" HasShadow="False" BackgroundColor="{DynamicResource CardBackgroundColor}" BorderColor="{DynamicResource AccentColor}" CornerRadius="8">
                                    <Image Source="{Binding ThumbnailUrl}" 
                                           Aspect="AspectFill" 
                                           HeightRequest="200" 
                                           WidthRequest="150">
                                        <Image.GestureRecognizers>
                                            <TapGestureRecognizer Command="{Binding BindingContext.OpenMediaDetailCommand, Source={x:Reference SearchPageRoot}}" 
                                                                  CommandParameter="{Binding .}" />
                                        </Image.GestureRecognizers>
                                    </Image>
                                </Frame>
                            </DataTemplate>
                        </CollectionView.ItemTemplate>
                    </CollectionView>
                </VerticalStackLayout>
            </Grid>
        </ScrollView>
    </Grid>
</ContentPage>
galacticlip_app/SearchViewModel.cs
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Maui.Controls;
using galacticlip_app.Models;
using galacticlip_app.Services;
using System.Windows.Input;

namespace galacticlip_app
{
    public class SearchViewModel : BindableObject
    {
        private readonly NasaMediaService _nasaMediaService;
        private string _searchQuery;

        public ObservableCollection<NasaMediaItem> LeftColumnImages { get; private set; }
        public ObservableCollection<NasaMediaItem> RightColumnImages { get; private set; }

        public string SearchQuery
        {
            get => _searchQuery;
            set
            {
                _searchQuery = value;
                OnPropertyChanged();
            }
        }

        public ICommand ExecuteSearchCommand { get; }
        public ICommand OpenMediaDetailCommand { get; }

        public SearchViewModel()
        {
            _nasaMediaService = new NasaMediaService();
            LeftColumnImages = new ObservableCollection<NasaMediaItem>();
            RightColumnImages = new ObservableCollection<NasaMediaItem>();

            // Load default random images initially
            LoadRandomImages();

            // Set up the command for the search
            ExecuteSearchCommand = new Command(async () => await SearchImages());

            OpenMediaDetailCommand = new Command<NasaMediaItem>(OnOpenMediaDetail);
        }

        private async void LoadRandomImages()
        {
            var images = await _nasaMediaService.GetRandomImagesAsync();
            SplitImagesIntoColumns(images);
        }

        private async Task SearchImages()
        {
            if (string.IsNullOrWhiteSpace(SearchQuery))
            {
                LoadRandomImages();
                return;
            }

            var searchedImages = await _nasaMediaService.SearchMediaAsync(SearchQuery);
            SplitImagesIntoColumns(searchedImages);
        }

        private void SplitImagesIntoColumns(List<NasaMediaItem> images)
        {
            LeftColumnImages.Clear();
            RightColumnImages.Clear();

            for (int i = 0; i < images.Count; i++)
            {
                if (i % 2 == 0)
                {
                    LeftColumnImages.Add(images[i]);
                }
                else
                {
                    RightColumnImages.Add(images[i]);
                }
            }
        }

        private async void OnOpenMediaDetail(NasaMediaItem item)
        {
            if (item != null)
            {
                await App.Current.MainPage.Navigation.PushModalAsync(new MediaDetailPage(item));
            }
        }

     
    }
}
galacticlip_app/Bookmarks.xaml
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="galacticlip_app.Bookmarks"
             x:Name="BookmarkPageRoot"
             Title=""
             NavigationPage.HasNavigationBar="False"
             BackgroundColor="{DynamicResource PageBackgroundColor}">

    <Grid RowDefinitions="Auto, *" Padding="10">

        <!-- Search Bar with Filter Button -->
        <Grid Grid.Row="0" ColumnDefinitions="*, Auto" Margin="0,10,0,10">
            <SearchBar x:Name="BookmarkSearchBar" Placeholder="Search bookmarks..."
                       FontSize="{DynamicResource MediumFontSize}"
                       FontFamily="{DynamicResource AppFontFamily}"
                       BackgroundColor="{DynamicResource CardBackgroundColor}"
                       TextColor="{DynamicResource TextColor}"
                       PlaceholderColor="{DynamicResource PlaceholderColor}"
                       Text="{Binding SearchQuery}"
                       SearchCommand="{Binding PerformSearchCommand}" />
 
        </Grid>

        <!-- List of Bookmarked Items -->
        <CollectionView Grid.Row="1"
                        ItemsSource="{Binding BookmarkedItems}"
                        SelectionMode="None">
            <!-- Empty View Placeholder -->
            <CollectionView.EmptyView>
                <VerticalStackLayout Padding="20" VerticalOptions="Start">
                    <Frame Padding="20" Margin="10" BorderColor="{DynamicResource Gray300Brush}" CornerRadius="8" BackgroundColor="{DynamicResource CardBackgroundColor}" 
                           WidthRequest="300" HeightRequest="150" HorizontalOptions="Center">
                        <VerticalStackLayout>
                            <Label Text="You currently have no bookmarked media." 
                                   FontSize="{DynamicResource MediumFontSize}" 
                                   FontAttributes="Bold"
                                   FontFamily="{DynamicResource AppFontFamily}"
                                   TextColor="{DynamicResource TextColor}"
                                   HorizontalTextAlignment="Center" 
                                   LineBreakMode="WordWrap" />
                            <Label Text="Try adding some from your home page or search page."
                                   FontSize="{DynamicResource MediumFontSize}"
                                   FontFamily="{DynamicResource AppFontFamily}"
                                   TextColor="{DynamicResource TextColor}"
                                   HorizontalTextAlignment="Center"
                                   LineBreakMode="WordWrap" />
                        </VerticalStackLayout>
                    </Frame>
                </VerticalStackLayout>
            </CollectionView.EmptyView>

            <CollectionView.ItemTemplate>
                <DataTemplate>
                    <SwipeView>
                        <!-- Swipe to Delete Action -->
                        <SwipeView.RightItems>
                            <SwipeItems Mode="Execute">
                                <SwipeItem Text="Remove"
                                           BackgroundColor="{DynamicResource AccentColor}"
                                           IconImageSource="delete_icon.png"
                                           Command="{Binding BindingContext.RemoveBookmarkCommand, Source={x:Reference BookmarkPageRoot}}"
                                           CommandParameter="{Binding .}"/>
                            </SwipeItems>
                        </SwipeView.RightItems>

                        <!-- Bookmark Item Layout -->
                        <Frame Padding="5" Margin="5" BorderColor="{DynamicResource Gray300Brush}" CornerRadius="8" BackgroundColor="{DynamicResource CardBackgroundColor}">
                            <Frame.GestureRecognizers>
                                <TapGestureRecognizer Command="{Binding BindingContext.OpenMediaDetailCommand, Source={x:Reference BookmarkPageRoot}}"
                              CommandParameter="{Binding .}" />
                            </Frame.GestureRecognizers>
                            <Grid ColumnDefinitions="Auto, *" Padding="10">
                                <Image Source="{Binding ThumbnailUrl}"
                                       Aspect="AspectFit"
                                       HeightRequest="60" WidthRequest="60" />      
                                <VerticalStackLayout Grid.Column="1" Padding="10,0,0,0">
                                    <Label Text="{Binding Title}" 
                                           FontAttributes="Bold" 
                                           FontSize="{DynamicResource MediumFontSize}" 
                                           FontFamily="{DynamicResource AppFontFamily}"
                                           TextColor="{DynamicResource TextColor}" 
                                           LineBreakMode="TailTruncation" />
                                    <Label Text="{Binding Description}" 
                                           FontSize="{DynamicResource MediumFontSize}" 
                                           FontFamily="{DynamicResource AppFontFamily}"
                                           TextColor="{DynamicResource TextColor}" 
                                           LineBreakMode="TailTruncation" 
                                           MaxLines="2" />
                                    <Label Text="{Binding DateCreated}" 
                                            FontSize="{DynamicResource MediumFontSize}" 
                                            FontFamily="{DynamicResource AppFontFamily}"
                                            FontAttributes="Italic" 
                                            TextColor="{DynamicResource TextColor}" />
                                </VerticalStackLayout>
                            </Grid>
                        </Frame>

                    </SwipeView>

                </DataTemplate>
            </CollectionView.ItemTemplate>
        </CollectionView>
    </Grid>
</ContentPage>
galacticlip_app/BookmarkViewModel.cs
using System.Collections.ObjectModel;
using System.Linq;
using Newtonsoft.Json;
using Microsoft.Maui.Controls;
using Microsoft.Maui.Storage;
using galacticlip_app.Models;
using System.Windows.Input;
namespace galacticlip_app.ViewModels
{
    public class BookmarkViewModel : BindableObject
    {
        private ObservableCollection<NasaMediaItem> _bookmarkedItems;
        private string _searchQuery;

        public ICommand OpenMediaDetailCommand { get; }

        public ObservableCollection<NasaMediaItem> BookmarkedItems
        {
            get => _bookmarkedItems;
            set
            {
                _bookmarkedItems = value;
                OnPropertyChanged();
            }
        }

        public string SearchQuery
        {
            get => _searchQuery;
            set
            {
                _searchQuery = value;
                OnPropertyChanged();
                FilterBookmarks(); // Automatically filter as user types
            }
        }

        public Command<NasaMediaItem> RemoveBookmarkCommand { get; }

        public BookmarkViewModel()
        {
            BookmarkedItems = LoadBookmarks();
            RemoveBookmarkCommand = new Command<NasaMediaItem>(RemoveBookmark);

            // Listen for "ClearBookmarks" message
            AppMessenger.MessageReceived += HandleMessage;

            OpenMediaDetailCommand = new Command<NasaMediaItem>(OnOpenMediaDetail);
        }

        private void HandleMessage(string message)
        {
            if (message == "ClearBookmarks")
            {
                // Clear the bookmarks collection and save changes
                BookmarkedItems.Clear();
                SaveBookmarks();
            }
        }

        // Ensure you unsubscribe to avoid memory leaks
        ~BookmarkViewModel()
        {
            AppMessenger.MessageReceived -= HandleMessage;
        }

        public ObservableCollection<NasaMediaItem> LoadBookmarks()
        {
            // Retrieve bookmarks from preferences
            var json = Preferences.Get("BookmarkedItems", string.Empty);
            if (string.IsNullOrWhiteSpace(json))
                return new ObservableCollection<NasaMediaItem>();

            var bookmarks = JsonConvert.DeserializeObject<List<NasaMediaItem>>(json);
            return new ObservableCollection<NasaMediaItem>(bookmarks ?? new List<NasaMediaItem>());
        }

        private void SaveBookmarks()
        {
            // Save the current list of bookmarks to preferences
            var json = JsonConvert.SerializeObject(BookmarkedItems.ToList());
            Preferences.Set("BookmarkedItems", json);

            // Notify other parts of the app
            NotifyBookmarksChanged();
        }

        private void NotifyBookmarksChanged()
        {
            AppMessenger.SendMessage("BookmarksUpdated");
        }

        private void RemoveBookmark(NasaMediaItem item)
        {
            // Remove the item and save the updated list
            BookmarkedItems.Remove(item);
            SaveBookmarks();
        }

        private void FilterBookmarks()
        {
            if (string.IsNullOrWhiteSpace(SearchQuery))
            {
                // Reload full list if query is empty
                BookmarkedItems = LoadBookmarks();
                return;
            }

            // Filter items based on the search query
            var filtered = BookmarkedItems
                .Where(item => item.Title.Contains(SearchQuery, StringComparison.OrdinalIgnoreCase) ||
                               item.Description.Contains(SearchQuery, StringComparison.OrdinalIgnoreCase))
                .ToList();

            BookmarkedItems = new ObservableCollection<NasaMediaItem>(filtered);
        }

        private async void OnOpenMediaDetail(NasaMediaItem item)
        {
            if (item != null)
            {
                await App.Current.MainPage.Navigation.PushModalAsync(new MediaDetailPage(item));
            }

        }
    }
}
galacticlip_app/MediaDetailViewModel.cs
using galacticlip_app.Models;
using System;
using System.Windows.Input;
using Microsoft.Maui.Controls;

namespace galacticlip_app
{
    public class MediaDetailViewModel : BindableObject
    {

        private const string NoteKeyPrefix = "MediaNote_";

        public string ThumbnailUrl { get; private set; }
        public string Title { get; private set; }
        public string Description { get; private set; }
        public DateTime DateCreated { get; private set; }
        public string Photographer { get; private set; }
        public string Keywords { get; private set; }

        private string _personalNote;
        public string PersonalNote
        {
            get => _personalNote;
            set
            {
                if (_personalNote != value)
                {
                    _personalNote = value;
                    OnPropertyChanged();

                    // Save the note whenever it changes
                    SavePersonalNote();
                }
            }
        }


        public ICommand BookmarkCommand { get; }
        public ICommand CloseCommand { get; }



        private string _noteKey;


        // Updated constructor to accept a NasaMediaItem directly
        public MediaDetailViewModel(NasaMediaItem mediaItem)
        {
            if (mediaItem != null)
            {
                ThumbnailUrl = mediaItem.ThumbnailUrl;
                Title = mediaItem.Title;
                Description = mediaItem.Description;
                DateCreated = mediaItem.DateCreated;
                Photographer = mediaItem.Photographer;
                Keywords = mediaItem.Keywords;

                // Use the item's unique identifier as the key
                _noteKey = $"{NoteKeyPrefix}{mediaItem.Id}";

                // Load saved note for this media item
                PersonalNote = Preferences.Get(_noteKey, string.Empty);
            }

            BookmarkCommand = new Command(OnBookmark);
            CloseCommand = new Command(OnClose);
        }

        private void SavePersonalNote()
        {
            Preferences.Set(_noteKey, _personalNote);
        }

        private async void OnBookmark()
        {
            await App.Current.MainPage.DisplayAlert("Bookmark", "Item bookmarked successfully.", "OK");
        }

        private async void OnClose()
        {
            await App.Current.MainPage.Navigation.PopModalAsync();
        }
    }
}
galacticlip_app/SettingsViewModel.cs
using System.Collections.ObjectModel;
using System.Windows.Input;
using Microsoft.Maui.Controls;
using Microsoft.Maui.Storage;

namespace galacticlip_app
{

    public class SharedResourceDictionary : ResourceDictionary { }

    public class SettingsViewModel : BindableObject
    {
        private string _userName;
        private string _selectedBackground;
        private bool _darkMode;
        private string _interfaceLanguage;
        private string _interfaceFont;
        private double _interfaceFontSize;

        private bool _isPortrait; // TEST: careful with this
        private bool _isLandscape; // TEST: careful with this

        public string UserName
        {
            get => _userName;
            set
            {
                _userName = value;
                Preferences.Set("UserName", value);
                MessagingCenter.Send(this, "UserNameUpdated", value);
                OnPropertyChanged();
            }
        }

        private string _selectedProfilePhoto;


        // CAREFUL WITH THIS FOLLOWING CODE BLOCK @ PROFILE PHOTO

            public ObservableCollection<string> AvailableProfilePhotos { get; } = new ObservableCollection<string>
            {
                "Star Explorer",
                "Cosmic Voyager",
                "Galaxy Dreamer"
            };

            // Selected profile photo (binding target)
            public string SelectedProfilePhoto
            {
                get => _selectedProfilePhoto;
                set
                {
                    _selectedProfilePhoto = value;
                    OnPropertyChanged();

                    // Update the profile image when selection changes
                    UpdateProfilePhoto(value);
                }
            }

            // Current profile photo resource path
            private string _userProfilePhoto = "profile1.png";
            public string UserProfilePhoto
            {
                get => _userProfilePhoto;
                
                private set
                {
                    _userProfilePhoto = value;
                    Preferences.Set("UserProfilePhoto", value);
                    OnPropertyChanged();
                }
            }

            // Update the profile photo based on selection
            private void UpdateProfilePhoto(string profileName)
            {
                // Map the profile name to the corresponding image file
                UserProfilePhoto = profileName switch
                {
                    "Star Explorer" => "profile1.png",
                    "Cosmic Voyager" => "profile2.png",
                    "Galaxy Dreamer" => "profile3.png",
                    _ => "profile1.png" // Default to the first profile
                };
            }

        // END OF CAREFUL CODE BLOCK @ PROFILE PHOTO
        public bool DarkMode
        {
            get => _darkMode;
            set
            {
                if (_darkMode != value)
                {
                    _darkMode = value;
                    Preferences.Set("DarkMode", value);

                    // Apply the theme change
                    if (Application.Current is App app)
                    {
                        app.ApplyTheme();
                    }
 
                }
            }
        }



        public ObservableCollection<string> AvailableLanguages { get; set; } = new()
        {
            "English", "Korean", "Simplified Chinese", "Spanish"
        };

        public string InterfaceLanguage
        {
            get => _interfaceLanguage;
            set
            {
                _interfaceLanguage = value;
                Preferences.Set("InterfaceLanguage", value);
                OnPropertyChanged();
                ApplyLanguage();
            }
        }

        public ObservableCollection<string> AvailableFonts { get; set; } = new()
        {
            "Roboto", "Playfair", "Doto"
        };

        public ObservableCollection<int> FontSizes { get; } = new()
        {
            12, 16, 20
        };

        private string _selectedFont;
        public string SelectedFont
        {
            get => _selectedFont;
            set
            {
                if (_selectedFont != value)
                {
                    _selectedFont = value;
                    Preferences.Set("AppFontFamily", value);

                    // Update the resource key dynamically
                    MainThread.BeginInvokeOnMainThread(() =>
                    {
                        var newDictionary = new ResourceDictionary();
                        newDictionary.Add("AppFontFamily", value);

                        Application.Current.Resources.MergedDictionaries.Clear();
                        Application.Current.Resources.MergedDictionaries.Add(newDictionary);
                    });


                    OnPropertyChanged();
                }
            }
        }

        private int _selectedFontSize;
        public int SelectedFontSize
        {
            get => _selectedFontSize;
            set
            {
                _selectedFontSize = value;
                Preferences.Set("AppFontSize", value);

                // Update global resources
                Application.Current.Resources["MediumFontSize"] = value;

                OnPropertyChanged();
            }
        }


        public ObservableCollection<string> AvailableBackgrounds { get; set; } = new()
        {
            "Default", "Galaxy", "Space"
        };

        public string SelectedBackground
        {
            get => _selectedBackground;
            set
            {
                _selectedBackground = value;
                Preferences.Set("BackgroundPreference", value);
                OnPropertyChanged();
            }
        }


        // CAREFUL WITH THIS FOLLOWING CODE BLOCK

        public bool IsPortrait
        {
            get => _isPortrait;
            set
            {
                if (_isPortrait != value)
                {
                    _isPortrait = value;
                    OnPropertyChanged();
                }
            }
        }

        public bool IsLandscape
        {
            get => _isLandscape;
            set
            {
                if (_isLandscape != value)
                {
                    _isLandscape = value;
                    OnPropertyChanged();
                }
            }
        }

        // END OF CAREFUL CODE BLOCK
        public string AppVersion { get; set; } = "1.0.0";
        public string SupportEmail { get; set; } = "[email protected]";

        public ICommand ClearBookmarksCommand { get; }

        public SettingsViewModel()
        {
            // Safely initialize settings from preferences
            _userName = Preferences.Get("UserName", "Guest");
            _darkMode = Preferences.Get("DarkMode", false);
            _interfaceLanguage = Preferences.Get("InterfaceLanguage", "English");
            _selectedFont = Preferences.Get("AppFontFamily", "Roboto");
            _selectedFontSize = Preferences.Get("AppFontSize", 16);
            _userProfilePhoto = Preferences.Get("UserProfilePhoto", "profile1.png");

            _selectedBackground = Preferences.Get("BackgroundPreference", "Default");

            ClearBookmarksCommand = new Command(ClearBookmarks);

            // CAREFUL WITH THIS FOLLOWING CODE BLOCK

            UpdateOrientation(DeviceDisplay.MainDisplayInfo.Orientation);
            DeviceDisplay.MainDisplayInfoChanged += OnDisplayInfoChanged;

            // END OF CAREFUL CODE BLOCK

        }

        // CAREFUL WITH THIS FOLLOWING CODE BLOCK

        private void OnDisplayInfoChanged(object sender, DisplayInfoChangedEventArgs e)
        {
            UpdateOrientation(e.DisplayInfo.Orientation);
        }

        private void UpdateOrientation(DisplayOrientation orientation)
        {
            IsPortrait = orientation == DisplayOrientation.Portrait;
            IsLandscape = orientation == DisplayOrientation.Landscape;
        }

        // END OF CAREFUL CODE BLOCK

        private void ClearBookmarks()
        {
            // Clear bookmarks from Preferences
            Preferences.Remove("BookmarkedItems");

            // Notify other parts of the app to react
            AppMessenger.SendMessage("ClearBookmarks");

            // Notify user
            App.Current.MainPage.DisplayAlert("Bookmarks", "All bookmarks have been cleared.", "OK");
        }



        private void ApplyLanguage()
        {
            // Dynamic language switching: Due to time and resources constraits a full multi-language implementation is not complete. 
            System.Diagnostics.Debug.WriteLine($"Language switched to: {InterfaceLanguage}");
        }
    }
}

Back to Home