diff --git a/assets/js/theme.js b/assets/js/theme.js index 395251dda3d2b6499ea6a0404a711468dd33e8e4..32ad7d7987cdfe23dfabbe6749e57a0cb9ea4239 100644 --- a/assets/js/theme.js +++ b/assets/js/theme.js @@ -1,47 +1,53 @@ const initTheme = () => { - // Check for saved theme preference or default to system preference - const savedTheme = localStorage.getItem('theme') || - (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); - - // Apply initial theme - document.documentElement.setAttribute('data-theme', savedTheme); - if (savedTheme === 'dark') { + // Check if user is logged in + if (window.currentUser) { + // For logged-in users, use their database preference + const userDarkMode = window.userDarkMode === true; + applyTheme(userDarkMode ? 'dark' : 'light'); + } else { + // For non-authenticated users, use localStorage + const savedTheme = localStorage.getItem('theme') || 'light'; + applyTheme(savedTheme); + } + + // Helper function to apply theme + function applyTheme(theme) { + if (theme === 'dark') { document.documentElement.classList.add('dark'); + } else { + document.documentElement.classList.remove('dark'); } - - // Add click handler to theme toggle button - document.addEventListener('click', (e) => { - const themeToggle = e.target.closest('[data-theme-toggle]'); - if (themeToggle) { - const currentTheme = localStorage.getItem('theme') || 'light'; - const newTheme = currentTheme === 'light' ? 'dark' : 'light'; - - // Update localStorage - localStorage.setItem('theme', newTheme); - - // Update document classes and data attributes - document.documentElement.setAttribute('data-theme', newTheme); - if (newTheme === 'dark') { - document.documentElement.classList.add('dark'); - } else { - document.documentElement.classList.remove('dark'); + localStorage.setItem('theme', theme); + } + + // Add click handler to theme toggle button + document.addEventListener('click', async (e) => { + const themeToggle = e.target.closest('[data-theme-toggle]'); + if (themeToggle) { + const isDark = !document.documentElement.classList.contains('dark'); + const newTheme = isDark ? 'dark' : 'light'; + + // Apply theme change + applyTheme(newTheme); + + // If user is logged in, save preference to server + if (window.currentUser) { + try { + await fetch('/api/update_theme', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': document.querySelector("meta[name='csrf-token']").content + }, + credentials: 'same-origin', + body: JSON.stringify({ is_dark_mode: isDark }) + }); + } catch (error) { + console.error('Error updating theme preference:', error); } } - }); - - // Listen for system theme changes - window.matchMedia('(prefers-color-scheme: dark)') - .addEventListener('change', e => { - if (!localStorage.getItem('theme')) { - const newTheme = e.matches ? 'dark' : 'light'; - document.documentElement.setAttribute('data-theme', newTheme); - if (newTheme === 'dark') { - document.documentElement.classList.add('dark'); - } else { - document.documentElement.classList.remove('dark'); - } - } - }); - }; - - export default initTheme; \ No newline at end of file + } + }); +}; + +export default initTheme; \ No newline at end of file diff --git a/features/contexts/dark_mode_context.exs b/features/contexts/dark_mode_context.exs index 1c7e324fa4076986a593a82588cb26c7a55db15a..05057a262ea1def2d829cb1fdc11f31e0e981daa 100644 --- a/features/contexts/dark_mode_context.exs +++ b/features/contexts/dark_mode_context.exs @@ -19,11 +19,18 @@ defmodule DarkModeContext do given_ ~r/^there exists following accounts$/, fn state, %{table_data: table} -> table - |> Enum.map(fn user_details -> User.changeset(%User{}, user_details) end) + |> Enum.map(fn user_details -> + # Handle is_dark_mode if present + user_details = case Map.get(user_details, :is_dark_mode) do + nil -> user_details + value when is_binary(value) -> Map.put(user_details, :is_dark_mode, value == "true") + _ -> user_details + end + User.changeset(%User{}, user_details) + end) |> Enum.each(fn changeset -> Repo.insert!(changeset) end) user = List.last(table) - { :ok, state @@ -93,23 +100,27 @@ defmodule DarkModeContext do when_ ~r/^I visit the property details page$/, fn state -> property = hd(state.advertisements) navigate_to("/properties/#{property.reference}") - :timer.sleep(1000) + :timer.sleep(500) {:ok, state} end # Light mode assertions then_ ~r/^I should see the application in light mode$/, fn state -> - assert get_css_variable("--color-background") == "#ffffff" - assert get_css_variable("--color-text") == "#1a1a1a" - assert get_css_variable("--color-card") == "#ffffff" + :timer.sleep(500) + html_classes = find_element(:css, "html") |> attribute_value("class") + refute String.contains?(html_classes, "dark") + assert has_classes?("body", ["bg-white", "text-zinc-900"]) + assert element_displayed?(find_element(:css, ".theme-toggle-button .hero-moon-solid")) {:ok, state} end # Dark mode assertions then_ ~r/^I should see the application in dark mode$/, fn state -> - assert get_css_variable("--color-background") == "#0f172a" - assert get_css_variable("--color-text") == "#e2e8f0" - assert get_css_variable("--color-card") == "#1e293b" + :timer.sleep(500) + html_classes = find_element(:css, "html") |> attribute_value("class") + assert String.contains?(html_classes, "dark") + assert has_classes?("body", ["dark:bg-dark-background", "dark:text-dark-text"]) + assert element_displayed?(find_element(:css, ".theme-toggle-button .hero-sun-solid")) {:ok, state} end @@ -123,7 +134,8 @@ defmodule DarkModeContext do end then_ ~r/^the background should be dark$/, fn state -> - assert get_css_variable("--color-background") == "#0f172a" + :timer.sleep(500) + assert has_classes?("body", ["dark:bg-dark-background"]) {:ok, state} end @@ -146,7 +158,8 @@ defmodule DarkModeContext do end then_ ~r/^the text should be light colored$/, fn state -> - assert get_css_variable("--color-text") == "#e2e8f0" + :timer.sleep(500) + assert has_classes?("body", ["dark:text-dark-text"]) # Using the actual class from your HTML {:ok, state} end @@ -171,17 +184,30 @@ defmodule DarkModeContext do end))} end + defp setup_session(email, password) do + fill_field({:id, "email"}, email) + fill_field({:id, "password"}, password) + click({:id, "login_button"}) + end + defp get_css_variable(variable_name) do script = """ - var style = getComputedStyle(document.documentElement); - return style.getPropertyValue('#{variable_name}').trim(); + var root = document.documentElement; + var theme = root.getAttribute('data-theme'); + var style = getComputedStyle(root); + var value = style.getPropertyValue('#{variable_name}').trim(); + console.log('Theme:', theme, 'Variable:', '#{variable_name}', 'Value:', value); + return value; """ execute_script(script) end - defp setup_session(email, password) do - fill_field({:id, "email"}, email) - fill_field({:id, "password"}, password) - click({:id, "login_button"}) + defp get_element_classes(selector) do + find_element(:css, selector) |> attribute_value("class") + end + + defp has_classes?(selector, classes) when is_list(classes) do + element_classes = get_element_classes(selector) + Enum.all?(classes, &String.contains?(element_classes, &1)) end end diff --git a/features/dark_mode.feature b/features/dark_mode.feature index d8528033e1938954dc81cb2b079fd16deb12b761..b8615dca3125a8ca9cb54e4966c94ea9b0065f58 100644 --- a/features/dark_mode.feature +++ b/features/dark_mode.feature @@ -13,18 +13,29 @@ Feature: FR-21: Dark/Light mode appearance switch And the background should be dark And the text should be light colored - Scenario: Dark mode persists across pages + Scenario: Dark mode user preference persists across pages Given there exists following accounts - | name | surname | birth_date | phone_number | email | password | confirm_password | - | Existing | Account | 2000-01-01 | 000 | existing.account@gmail.com | password | password | - And I have enabled dark mode + | name | surname | birth_date | phone_number | email | password | confirm_password | is_dark_mode | + | Existing | Account | 2000-01-01 | 000 | existing.account@gmail.com | password | password | true | When I navigate to the login page - Then I should see the login page in dark mode And I am logged in + Then I should see the application in dark mode When I navigate to the profile page - Then I should see the profile page in dark mode + Then I should see the application in dark mode When I navigate to the home page - Then I should see the home page in dark mode + Then I should see the application in dark mode + + Scenario: Light mode user preference persists across pages + Given there exists following accounts + | name | surname | birth_date | phone_number | email | password | confirm_password | is_dark_mode | + | Existing | Account | 2000-01-01 | 000 | existing.account@gmail.com | password | password | false | + When I navigate to the login page + And I am logged in + Then I should see the application in light mode + When I navigate to the profile page + Then I should see the application in light mode + When I navigate to the home page + Then I should see the application in light mode Scenario: Theme preference persists after refresh Given I have enabled dark mode @@ -43,6 +54,6 @@ Feature: FR-21: Dark/Light mode appearance switch Then I should see dark mode colors for: | Element | Color | | Background | #0f172a | - # | Text | #e2e8f0 | - # | Cards | #1e293b | - # | Buttons | #60a5fa | \ No newline at end of file + | Text | #e2e8f0 | + | Cards | #1e293b | + | Buttons | #60a5fa | \ No newline at end of file diff --git a/lib/proptrackr/accounts/user.ex b/lib/proptrackr/accounts/user.ex index 37f73eb90cb952bdc96ec5eb76dbc0b67452a4a8..2f23ababf6a28b371340905e6294e2b501418753 100644 --- a/lib/proptrackr/accounts/user.ex +++ b/lib/proptrackr/accounts/user.ex @@ -14,6 +14,7 @@ defmodule PropTrackr.Accounts.User do field :email, :string field :password, :string field :confirm_password, :string, virtual: true # Define confirm_password as virtual, not includes in database + field :is_dark_mode, :boolean, default: false has_many :properties, PropTrackr.Properties.Property has_many :searches, PropTrackr.Search @@ -23,7 +24,7 @@ defmodule PropTrackr.Accounts.User do def changeset(struct, params \\ %{}) do struct - |> cast(params, [:name, :surname, :birth_date, :phone_number, :bio, :email, :password, :confirm_password]) + |> cast(params, [:name, :surname, :birth_date, :phone_number, :bio, :email, :password, :confirm_password, :is_dark_mode]) |> validate_required([:name, :surname, :birth_date, :phone_number, :email, :password, :confirm_password]) |> validate_format(:name, ~r/^[a-zA-Z\s-]+$/, message: "must contain only English letters, spaces, or hyphens") |> validate_format(:surname, ~r/^[a-zA-Z\s-]+$/, message: "must contain only English letters, spaces, or hyphens") @@ -96,4 +97,10 @@ defmodule PropTrackr.Accounts.User do end end + def update_theme_preference(user, is_dark) do + user + |> cast(%{is_dark_mode: is_dark}, [:is_dark_mode]) + |> PropTrackr.Repo.update() + end + end diff --git a/lib/proptrackr_web/components/layouts/root.html.heex b/lib/proptrackr_web/components/layouts/root.html.heex index 6c40d4c24b404912496efbe8930ad9575aafb87e..c631faee131bdf381fa883acf25b52c2f9db1bb1 100644 --- a/lib/proptrackr_web/components/layouts/root.html.heex +++ b/lib/proptrackr_web/components/layouts/root.html.heex @@ -1,5 +1,12 @@ <!DOCTYPE html> -<html lang="en" class="[scrollbar-gutter:stable] dark:dark" data-theme="light"> +<html lang="en" + class={ + cond do + assigns[:current_user] && assigns[:current_user].is_dark_mode -> "[scrollbar-gutter:stable] dark" + assigns[:current_user] -> "[scrollbar-gutter:stable]" + true -> "[scrollbar-gutter:stable]" # For non-authenticated users, initial class will be set by JS + end + }> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> @@ -8,10 +15,16 @@ <%= assigns[:page_title] || "PropTrackr" %> </.live_title> <link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} /> + + <script> + window.currentUser = <%= if assigns[:current_user], do: "true", else: "false" %>; + window.userDarkMode = <%= if assigns[:current_user], do: "#{assigns[:current_user].is_dark_mode}", else: "null" %>; + </script> + <script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}> </script> </head> - <body class="bg-white dark:bg-dark-background text-zinc-900 dark:text-dark-text transition-colors duration-300"> + <body class="bg-white dark:bg-dark-background text-zinc-900 dark:text-dark-text transition-colors duration-300"> <%= @inner_content %> </body> -</html> +</html> \ No newline at end of file diff --git a/lib/proptrackr_web/controllers/theme_controller.ex b/lib/proptrackr_web/controllers/theme_controller.ex new file mode 100644 index 0000000000000000000000000000000000000000..927eeb6636bf2c3e3d65e71ee2accfb501297c6d --- /dev/null +++ b/lib/proptrackr_web/controllers/theme_controller.ex @@ -0,0 +1,30 @@ +# lib/proptrackr_web/controllers/theme_controller.ex +defmodule PropTrackrWeb.ThemeController do + use PropTrackrWeb, :controller + alias PropTrackr.Accounts.User + alias PropTrackr.Repo + + def update(conn, %{"is_dark_mode" => is_dark_mode}) do + case conn.assigns.current_user do + nil -> + conn + |> put_status(:unauthorized) + |> json(%{error: "User not authenticated"}) + + user -> + # Update user directly using Repo + case Repo.update(Ecto.Changeset.change(user, %{is_dark_mode: is_dark_mode})) do + {:ok, updated_user} -> + # Update session with new theme preference + conn + |> put_session(:user_theme, updated_user.is_dark_mode) + |> json(%{success: true, is_dark_mode: updated_user.is_dark_mode}) + + {:error, _changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Could not update theme preference"}) + end + end + end +end diff --git a/lib/proptrackr_web/router.ex b/lib/proptrackr_web/router.ex index f08c304ea8bd401b91df75b5ac2cfdfcff368d56..330a15f977e207df8f27865d7d66551a5d964d4c 100644 --- a/lib/proptrackr_web/router.ex +++ b/lib/proptrackr_web/router.ex @@ -62,6 +62,7 @@ defmodule PropTrackrWeb.Router do post "/properties/calculate_price", PropertiesController, :calculate_price post "/properties/:reference/interest", InterestController, :create delete "/properties/:reference/interest", InterestController, :delete + post "/update_theme", ThemeController, :update end # Other scopes may use custom stacks. diff --git a/priv/repo/migrations/20241206140323_add_dark_mode_to_users.exs b/priv/repo/migrations/20241206140323_add_dark_mode_to_users.exs new file mode 100644 index 0000000000000000000000000000000000000000..27819cb2d9091011aece18ec949ce8a8e2c1b631 --- /dev/null +++ b/priv/repo/migrations/20241206140323_add_dark_mode_to_users.exs @@ -0,0 +1,9 @@ +defmodule PropTrackr.Repo.Migrations.AddDarkModeToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :is_dark_mode, :boolean, default: false, null: false + end + end +end diff --git a/test/proptrackr_web/controllers/theme_controller_test.exs b/test/proptrackr_web/controllers/theme_controller_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..d9bd7137ac73ba6bf37721c99a4da784b8496ac8 --- /dev/null +++ b/test/proptrackr_web/controllers/theme_controller_test.exs @@ -0,0 +1,104 @@ +defmodule PropTrackrWeb.ThemeControllerTest do + use PropTrackrWeb.ConnCase + alias PropTrackr.Accounts.User + alias PropTrackr.Repo + + setup do + user = %User{ + name: "Test", + surname: "User", + birth_date: ~D[2000-01-01], + phone_number: "000", + bio: "Yo", + email: "test.user@gmail.com", + password: "testing", + confirm_password: "testing", + is_dark_mode: false + } + user = Repo.insert!(user) + + other_user = %User{ + name: "Other", + surname: "User", + birth_date: ~D[2000-01-01], + phone_number: "111", + bio: "Hey", + email: "other.user@gmail.com", + password: "testing", + confirm_password: "testing", + is_dark_mode: true + } + other_user = Repo.insert!(other_user) + + {:ok, %{user: user, other_user: other_user}} + end + + test "authenticated user can enable dark mode", %{conn: conn, user: user} do + conn = conn |> setup_session(user) + conn = post(conn, ~p"/api/update_theme", %{"is_dark_mode" => true}) + + assert json_response(conn, 200) == %{ + "success" => true, + "is_dark_mode" => true + } + + updated_user = Repo.get!(User, user.id) + assert updated_user.is_dark_mode == true + end + + test "authenticated user can disable dark mode", %{conn: conn, other_user: user} do + conn = conn |> setup_session(user) + conn = post(conn, ~p"/api/update_theme", %{"is_dark_mode" => false}) + + assert json_response(conn, 200) == %{ + "success" => true, + "is_dark_mode" => false + } + + updated_user = Repo.get!(User, user.id) + assert updated_user.is_dark_mode == false + end + + test "unauthenticated user cannot update theme", %{conn: conn} do + conn = post(conn, ~p"/api/update_theme", %{"is_dark_mode" => true}) + + assert json_response(conn, 401) == %{ + "error" => "User not authenticated" + } + end + + test "theme preference persists in session", %{conn: conn, user: user} do + conn = conn |> setup_session(user) + conn = post(conn, ~p"/api/update_theme", %{"is_dark_mode" => true}) + + assert get_session(conn, :user_theme) == true + + updated_user = Repo.get!(User, user.id) + assert updated_user.is_dark_mode == true + end + + test "user can toggle theme multiple times", %{conn: conn, user: user} do + conn = conn |> setup_session(user) + + # Enable dark mode + conn = post(conn, ~p"/api/update_theme", %{"is_dark_mode" => true}) + assert json_response(conn, 200) == %{"success" => true, "is_dark_mode" => true} + + # Disable dark mode + conn = post(conn, ~p"/api/update_theme", %{"is_dark_mode" => false}) + assert json_response(conn, 200) == %{"success" => true, "is_dark_mode" => false} + + # Enable dark mode again + conn = post(conn, ~p"/api/update_theme", %{"is_dark_mode" => true}) + assert json_response(conn, 200) == %{"success" => true, "is_dark_mode" => true} + + updated_user = Repo.get!(User, user.id) + assert updated_user.is_dark_mode == true + end + + defp setup_session(conn, user) do + conn = conn |> post("/login", email: user.email, password: user.password) + conn = get conn, redirected_to(conn) + conn + end +end