Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ui): updates + dynamically sized window #16

Merged
merged 21 commits into from
Aug 20, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
280 changes: 200 additions & 80 deletions main.py
Original file line number Diff line number Diff line change
@@ -1,96 +1,155 @@
from __future__ import annotations

from platform import system
from tkinter import Menu, Tk, messagebox
from tkinter.ttk import Button, Entry, Frame, Label
from tkinter import Event, Menu, Tk, Toplevel, messagebox
from tkinter.ttk import Button, Entry, Frame, Label, Progressbar, Style

from pyowm import OWM
from pyowm.commons.exceptions import APIRequestError
from pyowm.commons.exceptions import NotFoundError as OWMNotFoundError
from requests import Response
from requests import get as requests_get


class ProgressBar(Toplevel):
def __init__(self, parent: App, *args, **kwargs):
# Set up window
super().__init__(parent, *args, **kwargs)
self.title("Loading...")
self.resizable(False, False)

# Set up widgets
self.main_frame = Frame(self)
self.main_frame.grid()

main_label = Label(self.main_frame, text="Loading...", font="Helvetica 15 bold")
main_label.grid(padx=10, pady=10)

self.progressbar = Progressbar(
self.main_frame,
orient="horizontal",
length=200,
mode="indeterminate",
maximum=4,
)
self.progressbar.grid(padx=10, pady=10)

self.resize_app()

def resize_app(self) -> App:
"""Use tkinter to detect the minimum size of the app, get the center of the screen, and place the app there."""

# TODO: Make a global function for this to remove boilerplate code
# Update widgets so minimum size is accurate
self.update_idletasks()

# Get minimum size
minimum_width: int = self.winfo_reqwidth()
minimum_height: int = self.winfo_reqheight()

# Get center of screen based on minimum size
x_coords = int(self.winfo_screenwidth() / 2 - minimum_width / 2)
y_coords = int(self.wm_maxsize()[1] / 2 - minimum_height / 2)

# Place app and make the minimum size the actual minimum size (non-infringable)
self.geometry(f"{minimum_width}x{minimum_height}+{x_coords}+{y_coords}")
self.wm_minsize(minimum_width, minimum_height)
return self

def set_progress(self, progress: int):
self.progressbar.step(progress)


class App(Tk):
def __init__(self):
super().__init__()
self.withdraw()

# Set up Menubar
if system() == "Darwin":
self.menubar = Menu(self)

# Apple menus have special names and special commands
self.app_menu = Menu(self.menubar, tearoff=0, name="apple")
self.menubar.add_cascade(label="App", menu=self.app_menu)
else:
self.menubar = Menu(self)
self.app_menu = Menu(self.menubar, tearoff=0)
self.menubar.add_cascade(label="App", menu=self.app_menu)
self.menubar.add_command(label="About Weather", command=self.about)
self.app_menu.add_command(label="About Weather", command=self.about)
self.config(menu=self.menubar)

# Set up style
Style().theme_use("clam")

# Set up window
self.title("Weather")
self.resizable(False, False)
self.configure(bg="white")

# Set up widgets
self.main_frame = Frame(self, padding=10)
self.main_frame = Frame(self)
self.main_frame.pack()

heading = Label(self.main_frame, text="Weather", font="Helvetica 13")
heading = Label(self.main_frame, text="Weather", font="Helvetica 25 bold")
heading.grid(row=0, column=0, columnspan=2, padx=10, pady=10)

self.cityname = Label(self.main_frame, text="", font=("Helvetica 13"))
self.cityname = Label(self.main_frame, text="City: None", font=("Helvetica 15"))
self.cityname.grid(row=1, column=0, columnspan=2)

self.searchbar = Entry(self.main_frame, width=42)
self.searchbar.grid(row=2, column=0, columnspan=2, padx=10, pady=10)
self.bind("<Return>", self.OWMCITY)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is good to notice this! I tried using the same bind() function, but it didn't work. Maybe because I had to use command= self.OWMCITY. I'll test this after lunch.


self.info_frame = Frame(self.main_frame, relief="sunken")
self.info_frame.grid(row=3, column=0, columnspan=2, padx=10, pady=10)

self.label_status = Label(
self.main_frame, text="", font=("Helvetica 13"))
self.label_status.grid(row=3, column=0, columnspan=2)
self.label_weather = Label(self.info_frame, text="", font=("Helvetica 13"))
self.label_weather.grid(row=0, column=0, columnspan=2)

self.label_temp = Label(
self.main_frame, text="", font=("Helvetica 13"))
self.label_temp.grid(row=4, column=0, columnspan=2)
self.label_temp = Label(self.info_frame, text="", font=("Helvetica 13"))
self.label_temp.grid(row=1, column=0, columnspan=2)

self.label_temp_max = Label(
self.main_frame, text="", font=("Helvetica 13"))
self.label_temp_max.grid(row=5, column=0, columnspan=2)
self.label_temp_max = Label(self.info_frame, text="", font=("Helvetica 13"))
self.label_temp_max.grid(row=2, column=0, columnspan=2)

self.label_temp_min = Label(
self.main_frame, text="", font=("Helvetica 13"))
self.label_temp_min.grid(row=6, column=0, columnspan=2)
self.label_temp_min = Label(self.info_frame, text="", font=("Helvetica 13"))
self.label_temp_min.grid(row=3, column=0, columnspan=2)

self.label_feels_like = Label(
self.main_frame, text="", font=("Helvetica 13"))
self.label_feels_like.grid(row=7, column=0, columnspan=2)
self.label_feels_like = Label(self.info_frame, text="", font=("Helvetica 13"))
self.label_feels_like.grid(row=4, column=0, columnspan=2)

self.label_humidity = Label(
self.main_frame, text="", font=("Helvetica 13"))
self.label_humidity.grid(row=8, column=0, columnspan=2)
self.label_humidity = Label(self.info_frame, text="", font=("Helvetica 13"))
self.label_humidity.grid(row=5, column=0, columnspan=2)

self.label_pressure = Label(
self.main_frame, text="", font=("Helvetica 13"))
self.label_pressure.grid(row=9, column=0, columnspan=2)
self.label_pressure = Label(self.info_frame, text="", font=("Helvetica 13"))
self.label_pressure.grid(row=6, column=0, columnspan=2)

self.label_visibility = Label(
self.main_frame, text="", font=("Helvetica 13"))
self.label_visibility.grid(row=10, column=0, columnspan=2)
self.label_visibility = Label(self.info_frame, text="", font=("Helvetica 13"))
self.label_visibility.grid(row=7, column=0, columnspan=2)

self.label_windspeed = Label(
self.main_frame, text="", font=("Helvetica 13"))
self.label_windspeed.grid(row=11, column=0, columnspan=2)
Button(self.main_frame, text="Search for City", command=self.OWMCITY).grid(
row=12, column=0, padx=10, pady=10
self.label_windspeed = Label(self.info_frame, text="", font=("Helvetica 13"))
self.label_windspeed.grid(row=8, column=0, columnspan=2)

# Set up buttons
self.start_button = Button(
self.main_frame, text="Search for City", command=self.OWMCITY
)
self.start_button.grid(row=12, column=0, padx=10, pady=10)
Button(self.main_frame, text="Exit", command=self.exit_app).grid(
row=12, column=1, padx=10, pady=10
)

# Set variables
self.searching: bool = False

# Resize and deiconify
self.resize_app()
self.deiconify()

def about(self) -> App:
"""Display a messagebox with information about the app."""

messagebox.showinfo(
"About Weather",
"Weather is a simple weather app that uses the OpenWeatherMap API to get the weather for a given city.",
Expand All @@ -100,6 +159,7 @@ def about(self) -> App:

def resize_app(self) -> App:
"""Use tkinter to detect the minimum size of the app, get the center of the screen, and place the app there."""

# Update widgets so minimum size is accurate
self.update_idletasks()

Expand All @@ -109,79 +169,139 @@ def resize_app(self) -> App:

# Get center of screen based on minimum size
x_coords = int(self.winfo_screenwidth() / 2 - minimum_width / 2)
y_coords = int(self.winfo_screenheight() / 2 - minimum_height / 2) - 20
# `-20` should deal with Dock on macOS and looks good on other OS's
y_coords = int(self.wm_maxsize()[1] / 2 - minimum_height / 2)

# Place app and make the minimum size the actual minimum size (non-infringable)
self.geometry(
f"{minimum_width}x{minimum_height}+{x_coords}+{y_coords}")
self.geometry(f"{minimum_width}x{minimum_height}+{x_coords}+{y_coords}")
self.wm_minsize(minimum_width, minimum_height)
return self

def exit_app(self) -> None:
"""Exit the app."""
self.destroy()

def OWMCITY(self) -> App:
def OWMCITY(self, _: Event | None = None) -> None:
"""Get the weather for a given city using the OpenWeatherMap API and display it in a label."""

# Check if already searching
if self.searching:
return
self.searching = True

# Start the Progress Bar, disable buttons and clear labels, and disable searchbar
self.start_button.configure(state="disabled")
self.searchbar.configure(state="disabled")
self.update_labels()
pb = ProgressBar(self)

# Get API key
api_key: str = "c439e1209216cc7e7c73a3a8d1d12bfd"
owm = OWM(api_key)
mgr = owm.weather_manager()
# Get city name
city: str = self.searchbar.get()
observation = mgr.weather_at_place(city)
self.searchbar.delete(0, "end")

# Check if city is empty
if not city:
# TODO: Make a method to reduce code duplication (boilerplate)
self.cityname.configure(text="City: Needs Name")
self.update_labels()
self.start_button.configure(state="normal")
self.searchbar.configure(state="normal")
pb.destroy()
self.searching = False
self.resize_app() # In case the name gets too long or it renders differently on other systems
return

pb.progressbar.step()

# Check if city exists
try:
observation = mgr.weather_at_place(city)
except OWMNotFoundError or APIRequestError:
self.cityname.configure(text="City: Not Found")
self.update_labels()
self.start_button.configure(state="normal")
self.searchbar.configure(state="normal")
pb.destroy()
self.searching = False
self.resize_app() # In case the name gets too long or it renders differently on other systems
return

pb.progressbar.step()

# Get weather data
weather = observation.weather

# Send request to OpenWeatherMap API
response: Response = requests_get(
f"http://api.openweathermap.org/data/2.5/weather?q={city}&appid={api_key}"
)

# Check if request was successful
if response.status_code != 200:
self.label_status.configure(text="City not found")
self.cityname.configure(text="City: Connection Error")
self.update_labels()
self.start_button.configure(state="normal")
self.searchbar.configure(state="normal")
pb.destroy()
self.searching = False
self.resize_app() # In case the name gets too long or it renders differently on other systems
return

pb.progressbar.step()

# Get response data, simplify and create variables for usage
# TODO: Give all data in dual columns
# | Weather: Clear | Temp: 20°C |
# TODO: Allow users to change between imperial and metric (Fahrenheit and Celsius)
data = response.json()
main = data["main"]
temperature = weather.temperature("celsius")
wind = weather.wind(unit="meters_sec")
# Get temperature in Celsius
# temperature_kelvin: float = main["temp"]
# temperature_celsius = temperature_kelvin - 273.15
temp = temperature.get("temp", None)
temp_max = temperature.get("temp_max", None)
temp_min = temperature.get("temp_min", None)
feels_like = temperature.get("feels_like", None)
status: str = weather.status
detailed_status: str = weather.detailed_status
# Other data needed from the API
humidity = main["humidity"]
pressure = main["pressure"]
visibility = weather.visibility(unit="kilometers")
windspeed: float = wind["speed"]
# Put in label
self.cityname.configure(
text=data["name"] + ", " + data["sys"]["country"])
self.label_status.configure(
text="Weather: " + status + ":- " + detailed_status)
self.label_temp.configure(text="Temperature: " + f"{temp:.2f}°C")
self.label_temp_max.configure(
text="Maximum Temperature: " + f"{temp_max:.2f}°C"
)
self.label_temp_min.configure(
text="Minimum Temperature: " + f"{temp_min:.2f}°C"
)
self.label_feels_like.configure(
text="Feels like " + f"{feels_like:.2f}°C")
self.label_humidity.configure(text="Humidity: " + f"{humidity:.2f}%")
self.label_pressure.configure(
text="Pressure: " + f"{pressure:.2f}" + " hPa")
self.label_visibility.configure(
text="Visibility: " + f"{visibility:.2f}" + " km"
)
self.label_windspeed.configure(
text="Wind Speed: " + f"{windspeed:.2f}" + " meters per second"

# Update labels
self.update_labels(
[
f"Weather: {weather.status} ~ {weather.detailed_status}",
f"Current Temperature: {temperature.get('temp', None):.2f}°C",
f"Maximum Temperature: {temperature.get('temp_max', None):.2f}°C",
f"Minimum Temperature: {temperature.get('temp_min', None):.2f}°C",
f"Feels like {temperature.get('feels_like', None):.2f}°C",
f"Humidity: {main['humidity']:.2f}%",
f"Pressure: {main['pressure']:.2f} hPa",
f"Visibility: {weather.visibility(unit='kilometers'):.2f} km",
f"Wind Speed: { weather.wind(unit='meters_sec')['speed']:.2f} meters per second",
]
)
return self

# Set the city name
self.cityname.configure(text=f"City: {city}")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# Set the city name
self.cityname.configure(text=f"City: {city}")
cityname = data["name"] + ", " + data["sys"]["country"]
# Set the city name
self.cityname.configure(text=f"City: {cityname}")

SUGGESTION

This change is simple: it takes the name of the city from the API(data["name") and adds it with the country code(data["sys"]["country"]). Now, even if a person enters a redundant name, it will change to the official name.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you please put it all in the f-string?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, isn't it already in a f-string? What are you referring to?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for a variable is what I mean.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, now I get it. I'll fix it, I need some time. It's already nearing midnight and I'm doing homework. The Life of A Teenage Coder?

I will commit it, no problem.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you do that, I will not work on it though.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can improve the layout if you make a better one window idea though.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But then how can we integrate the settings into our main window?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It already is. It could use a better design, sure, but a whole window for two settings? That's a little over the top.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could repurpose the same window for settings too. Any ideas on this type? We could use a tabbed window. I'll explain.


# Update the Progress Bar one last time
print("Updating Progress Bar one last time")
pb.progressbar.step()

# Stop the Progress Bar, enable buttons and searchbar, and set searching to False
self.start_button.configure(state="normal")
self.searchbar.configure(state="normal")
pb.destroy()
self.searching = False
self.resize_app() # In case the name gets too long or it renders differently on other systems

def update_labels(self, data: list[str] = ["" for _ in range(9)]) -> None:
"""Clear all weather labels."""

self.label_weather.configure(text=data[0])
self.label_temp.configure(text=data[1])
self.label_temp_max.configure(text=data[2])
self.label_temp_min.configure(text=data[3])
self.label_feels_like.configure(text=data[4])
self.label_humidity.configure(text=data[5])
self.label_pressure.configure(text=data[6])
self.label_visibility.configure(text=data[7])
self.label_windspeed.configure(text=data[8])
return None


if __name__ == "__main__":
Expand Down
Loading
Loading