""" Components/TimePicker ===================== .. seealso:: `Material Design spec, Time picker `_ .. rubric:: Includes time picker. .. image:: https://github.com/HeaTTheatR/KivyMD-data/raw/master/gallery/kivymddoc/picker-previous.png :align: center .. warning:: The widget is under testing. Therefore, we would be grateful if you would let us know about the bugs found. .. rubric:: Usage .. tabs:: .. tab:: Declarative KV style .. code-block:: python from kivy.lang import Builder from kivymd.app import MDApp from kivymd.uix.pickers import MDTimePicker KV = ''' MDFloatLayout: MDRaisedButton: text: "Open time picker" pos_hint: {'center_x': .5, 'center_y': .5} on_release: app.show_time_picker() ''' class Test(MDApp): def build(self): self.theme_cls.theme_style = "Dark" self.theme_cls.primary_palette = "Orange" return Builder.load_string(KV) def show_time_picker(self): '''Open time picker dialog.''' time_dialog = MDTimePicker() time_dialog.open() Test().run() .. tab:: Declarative python style .. code-block:: python from kivymd.app import MDApp from kivymd.uix.button import MDRaisedButton from kivymd.uix.pickers import MDTimePicker from kivymd.uix.screen import MDScreen class Test(MDApp): def build(self): self.theme_cls.theme_style = "Dark" self.theme_cls.primary_palette = "Orange" return ( MDScreen( MDRaisedButton( text="Open time picker", pos_hint={'center_x': .5, 'center_y': .5}, on_release=self.show_time_picker, ) ) ) def show_time_picker(self, *args): '''Open time picker dialog.''' MDTimePicker().open() Test().run() .. image:: https://github.com/HeaTTheatR/KivyMD-data/raw/master/gallery/kivymddoc/MDTimePicker.png :align: center Binding method returning set time --------------------------------- .. code-block:: python def show_time_picker(self): time_dialog = MDTimePicker() time_dialog.bind(time=self.get_time) time_dialog.open() def get_time(self, instance, time): ''' The method returns the set time. :type instance: :type time: ''' return time Open time dialog with the specified time ---------------------------------------- Use the :attr:`~MDTimePicker.set_time` method of the :class:`~MDTimePicker.` class. .. code-block:: python def show_time_picker(self): from datetime import datetime # Must be a datetime object previous_time = datetime.strptime("03:20:00", '%H:%M:%S').time() time_dialog = MDTimePicker() time_dialog.set_time(previous_time) time_dialog.open() .. note:: For customization of the :class:`~MDTimePicker` class, see the documentation in the :class:`~kivymd.uix.pickers.datepicker.datepicker.BaseDialogPicker` class. .. code-block:: python MDTimePicker( primary_color="brown", accent_color="red", text_button_color="white", ).open() .. image:: https://github.com/HeaTTheatR/KivyMD-data/raw/master/gallery/kivymddoc/time-picker-customization.png :align: center """ __all__ = ("MDTimePicker",) import datetime import os import re import time from typing import List, Union from kivy.animation import Animation from kivy.clock import Clock from kivy.event import EventDispatcher from kivy.lang import Builder from kivy.metrics import dp from kivy.properties import ( BooleanProperty, ColorProperty, ListProperty, NumericProperty, ObjectProperty, OptionProperty, StringProperty, VariableListProperty, ) from kivy.uix.behaviors import ButtonBehavior from kivy.vector import Vector from kivymd import uix_path from kivymd.theming import ThemableBehavior from kivymd.uix.boxlayout import MDBoxLayout from kivymd.uix.circularlayout import MDCircularLayout from kivymd.uix.label import MDLabel from kivymd.uix.pickers.datepicker import BaseDialogPicker from kivymd.uix.relativelayout import MDRelativeLayout from kivymd.uix.textfield import MDTextField with open( os.path.join(uix_path, "pickers", "timepicker", "timepicker.kv"), encoding="utf-8", ) as kv_file: Builder.load_string(kv_file.read()) class AmPmSelectorLabel(ButtonBehavior, MDLabel): pass class AmPmSelector(ThemableBehavior, MDBoxLayout): border_radius = NumericProperty() border_color = ColorProperty() bg_color = ColorProperty() bg_color_active = ColorProperty() border_width = NumericProperty() am = ObjectProperty() am = ObjectProperty() owner = ObjectProperty() text_color = ColorProperty() selected = StringProperty() _am_bg_color = ColorProperty() _pm_bg_color = ColorProperty() def __init__(self, **kwargs): super().__init__(**kwargs) self.bind(selected=self._upadte_color) Clock.schedule_once(self._upadte_color) def _upadte_color(self, *args): bg_color = ( self.owner.accent_color if self.owner.accent_color else self.bg_color_active ) if self.selected == "am": self._am_bg_color = bg_color self._pm_bg_color = ( self.owner.primary_color if self.owner.accent_color else self.bg_color ) elif self.selected == "pm": self._am_bg_color = ( self.owner.primary_color if self.owner.accent_color else self.bg_color ) self._pm_bg_color = bg_color class TimeInputTextField(MDTextField): num_type = OptionProperty("hour", options=["hour", "minute"]) hour_regx = "^[0-9]$|^0[1-9]$|^1[0-2]$" minute_regx = "^[0-9]$|^0[0-9]$|^[1-5][0-9]$" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) Clock.schedule_once(self.set_text) self.register_event_type("on_select") self.bind(text_color_focus=self.setter("hint_text_color_normal")) def validate_time(self, text) -> Union[None, re.Match]: reg = self.hour_regx if self.num_type == "hour" else self.minute_regx return re.match(reg, text) def insert_text(self, text, from_undo=False): strip_text = self.text.strip() current_string = "".join([strip_text, text]) if not self.validate_time(current_string): text = "" return super().insert_text(text, from_undo=from_undo) def set_text(self, *args) -> None: """ Texts should be center aligned. Now we are setting the padding of text to somehow make them aligned. """ def set_text(*args): if not self.text: self.text = " " self._refresh_text(self.text) max_size = max(self._lines_rects, key=lambda r: r.size[0]).size dx = (self.width - max_size[0]) / 2.0 dy = (self.height - max_size[1]) / 2.0 self.padding = [dx, dy, dx, dy] if len(self.text) > 1: self.text = self.text.replace(" ", "") Clock.schedule_once(set_text) def on_focus(self, *args) -> None: super().on_focus(*args) if self.text.strip(): if ( not self.focus and int(self.text) == 0 and self.num_type == "hour" ): self.text = "12" else: self.text = " 12" if self.num_type == "hour" else " 00" def on_select(self, *args) -> None: pass def on_touch_down(self, touch): if self.collide_point(*touch.pos): self.dispatch("on_select") super().on_touch_down(touch) class TimeInput(MDRelativeLayout): """Implements two text fields for displaying and entering a time value.""" bg_color = ColorProperty() bg_color_active = ColorProperty() text_color = ColorProperty() disabled = BooleanProperty(True) minute_radius = ListProperty([0, 0, 0, 0]) hour_radius = ListProperty([0, 0, 0, 0]) state = StringProperty("hour") _hour = ObjectProperty() _minute = ObjectProperty() def __init__(self, **kwargs): super().__init__(**kwargs) self.register_event_type("on_time_input") self.register_event_type("on_hour_select") self.register_event_type("on_minute_select") def set_time(self, time_list) -> None: hour, minute = time_list self._hour.text = hour self._minute.text = minute def get_time(self) -> List[str]: hour = self._hour.text.strip() minute = self._minute.text.strip() return [hour, minute] def on_time_input(self, *args) -> None: pass def on_minute_select(self, *args) -> None: pass def on_hour_select(self, *args) -> None: pass def _update_padding(self, *args): self._hour.set_text() self._minute.set_text() class SelectorLabel(MDLabel): pass class CircularSelector(MDCircularLayout, EventDispatcher): """Implements clock face display.""" mode = OptionProperty("hour", options=["hour", "minute"]) # and military text_color = ColorProperty() selected_hour = StringProperty("12") selected_minute = StringProperty("0") selector_size = NumericProperty("48dp") selector_pos = ListProperty([0, 0]) selector_color = ColorProperty() bg_color = ColorProperty() font_name = StringProperty() scale = NumericProperty(1) content_scale = NumericProperty(1) t = StringProperty("out_quad") d = NumericProperty(0.2) scale_origin = ListProperty([100, 100]) _centers_pos = ListProperty() def __init__(self, **kwargs): super().__init__(**kwargs) self.bind( mode=self._update_labels, selected_hour=self.update_time, selected_minute=self.update_time, ) Clock.schedule_once(lambda x: self._update_labels(animate=False)) self.register_event_type("on_selector_change") def do_layout(self, *largs, **kwargs): self.update_time() return super().do_layout(*largs, **kwargs) def set_selector(self, selected) -> bool: """Sets the selector's position towards the given text.""" widget = None for wid in self.children: wid.text_color = self.text_color if wid.text == selected: widget = wid if not widget: return False self.selector_pos = widget.center widget.text_color = [1, 1, 1, 1] self.dispatch("on_selector_change") return True def set_time(self, selected) -> None: if self.mode == "hour": self.selected_hour = selected elif self.mode == "minute": self.selected_minute = selected def update_time(self, *args) -> None: if self.mode == "hour": self.set_selector(self.selected_hour) elif self.mode == "minute": self.set_selector(self.selected_minute) def get_selected(self) -> str: return self.selected def switch_mode(self, mode) -> None: if mode != self.mode: self.mode = mode def on_touch_down(self, touch): if self.collide_point(*touch.pos): touch.grab(self) closest_wid = self._get_closest_widget(touch.pos) self.set_time(closest_wid.text) return True def on_touch_move(self, touch): if touch.grab_current == self: closest_wid = self._get_closest_widget(touch.pos) self.set_time(closest_wid.text) def on_touch_up(self, touch): if touch.grab_current is self: touch.ungrab(self) return True def on_selector_change(self, *args): pass def _update_labels(self, animate=True, *args): """ This method builds the selector based on current mode which currently can be hour or minute. """ if self.mode == "hour": param = (1, 12) self.degree_spacing = 30 self.start_from = 60 elif self.mode == "minute": param = (0, 59, 5) self.degree_spacing = 6 self.start_from = 90 elif self.mode == "military": param = (1, 24) self.degree_spacing = 30 self.start_from = 90 if animate: anim = Animation(content_scale=0, t=self.t, d=self.d) anim.bind(on_complete=lambda *args: self._add_items(*param)) anim.start(self) else: self._add_items(*param) def _add_items(self, start, end, step=1): """ Adds all number in range `[start, end + 1]` to the circular layout with the specified step. Step means that all widgets will be added to layout but sets the opacity for skipped widgets to `0` because we are using the label's text as a reference to the selected number so we have to add these to layout. """ self.clear_widgets() i = 0 for x in range(start, end + 1): label = SelectorLabel( text=f"{x}", ) if i % step != 0: label.opacity = 0 self.bind( text_color=label.setter("text_color"), font_name=label.setter("font_name"), ) self.add_widget(label) i += 1 Clock.schedule_once(self.update_time) Clock.schedule_once(self._get_centers, 0.1) anim = Animation(content_scale=1, t=self.t, d=self.d) anim.start(self) def _get_centers(self, *args): """ Returns a list of all center. we use this for positioning the selector indicator. """ self._centers_pos = [] for child in self.children: self._centers_pos.append(child.center) def _get_closest_widget(self, pos): """ Returns the nearest widget to the given position. we use this to create the magnetic effect. """ distance = [Vector(pos).distance(point) for point in self._centers_pos] if not distance: return False index = distance.index(min(distance)) return self.children[index] class MDTimePicker(BaseDialogPicker): hour = StringProperty("12") """ Current hour. :attr:`hour` is an :class:`~kivy.properties.StringProperty` and defaults to `'12'`. """ minute = StringProperty("0") """ Current minute. :attr:`minute` is an :class:`~kivy.properties.StringProperty` and defaults to `0`. """ minute_radius = VariableListProperty(dp(5), length=4) """ Radius of the minute input field. .. image:: https://github.com/HeaTTheatR/KivyMD-data/raw/master/gallery/kivymddoc/time-picker-minute-radius.png :align: center :attr:`minute_radius` is an :class:`~kivy.properties.ListProperty` and defaults to `[dp(5), dp(5), dp(5), dp(5)]`. """ hour_radius = VariableListProperty(dp(5), length=4) """ Radius of the hour input field. .. image:: https://github.com/HeaTTheatR/KivyMD-data/raw/master/gallery/kivymddoc/time-picker-hour-radius.png :align: center :attr:`hour_radius` is an :class:`~kivy.properties.ListProperty` and defaults to `[dp(5), dp(5), dp(5), dp(5)]`. """ am_pm_radius = NumericProperty("5dp") """ Radius of the AM/PM selector. .. image:: https://github.com/HeaTTheatR/KivyMD-data/raw/master/gallery/kivymddoc/time-picker-am-pm-radius.png :align: center :attr:`am_pm_radius` is an :class:`~kivy.properties.NumericProperty` and defaults to `dp(5)`. """ am_pm_border_width = NumericProperty("1dp") """ Width of the AM/PM selector's borders. .. image:: https://github.com/HeaTTheatR/KivyMD-data/raw/master/gallery/kivymddoc/time-picker-am-pm-border-width.png :align: center :attr:`am_pm_border_width` is an :class:`~kivy.properties.NumericProperty` and defaults to `dp(1)`. """ am_pm = OptionProperty("am", options=["am", "pm"]) """ Current AM/PM mode. :attr:`am_pm` is an :class:`~kivy.properties.OptionProperty` and defaults to `'am'`. """ animation_duration = NumericProperty(0.3) """ Duration of the animations. :attr:`animation_duration` is an :class:`~kivy.properties.NumericProperty` and defaults to `0.2`. """ animation_transition = StringProperty("out_quad") """ Transition type of the animations. :attr:`animation_transition` is an :class:`~kivy.properties.StringProperty` and defaults to `'out_quad'`. """ time = ObjectProperty(allownone=True) """ Returns the current time object. :attr:`time` is an :class:`~kivy.properties.ObjectProperty` and defaults to `None`. """ _state = StringProperty() _selector = ObjectProperty() _time_input = ObjectProperty() _am_pm_selector = ObjectProperty() _hour_label = ObjectProperty() _minute_label = ObjectProperty() _anim_playing = BooleanProperty(False) def __init__(self, **kwargs): super().__init__(**kwargs) self.bind( hour=self._set_current_time, minute=self._set_current_time, am_pm=self._set_current_time, ) self.theme_cls.bind(device_orientation=self._check_orienation) if self.title == "SELECT DATE": self.title = "SELECT TIME" self.set_time(datetime.time(hour=12, minute=0)) # default time self._check_orienation() def set_time(self, time_obj) -> None: """Manually set time dialog with the specified time.""" hour = time_obj.hour minute = time_obj.minute if hour > 12: hour -= 12 mode = "pm" else: mode = "am" hour = str(hour) minute = str(minute) self._set_time_input(hour, minute) self._set_dial_time(hour, minute) self._set_am_pm(mode) def get_state(self) -> str: """ Returns the current state of TimePicker. Can be one of `portrait`, `landscape` or `input`. """ return self._state def _get_dial_time(self, instance): mode = instance.mode if mode == "hour": self.hour = instance.selected_hour elif mode == "minute": self.minute = instance.selected_minute else: raise Exception("invalid mode for MDTimePicker: " % mode) self._set_time_input(self.hour, self.minute) def _set_dial_time(self, hour, minute): self._selector.selected_minute = minute self._selector.selected_hour = hour def _get_time_input(self, hour, minute): if hour: self.hour = f"{int(hour):01d}" if minute: self.minute = f"{int(minute):01d}" self._set_dial_time(self.hour, self.minute) def _set_time_input(self, hour, minute): hour = f"{int(hour):02d}" minute = f"{int(minute):02d}" if self._state != "input": self._time_input.set_time([hour, minute]) def _get_am_pm(self, selected): self.am_pm = selected def _set_am_pm(self, selected: str) -> None: """Used by set_time() to manually set the mode to "am" or "pm".""" self.am_pm = selected self._am_pm_selector.mode = self.am_pm self._am_pm_selector.selected = self.am_pm def _get_data(self): try: if time.strftime("%p"): result = datetime.datetime.strptime( f"{int(self.hour):02d}:{int(self.minute):02d} {self.am_pm}", "%I:%M %p", ).time() else: result = datetime.datetime.strptime( f"{int(self.hour):02d}:{int(self.minute):02d}", "%I:%M", ).time() return result except ValueError: return None # hour is zero def _check_orienation(self, *args, do_anim=False): orientation = self.theme_cls.device_orientation if self._state != "input" and orientation != self._state: self._update_pos_size(orientation, anim=do_anim) def _update_pos_size(self, orientation, anim=False): d = self.animation_duration # time input time_input_pos = ( [dp(24), dp(368)] if orientation == "portrait" else ( [dp(24), dp(178)] if orientation == "landscape" else [dp(24), dp(96)] ) ) if anim: _time_input = Animation( pos=time_input_pos, d=d, t=self.animation_transition, # 80 - 8, ) _time_input.start(self._time_input) else: self._time_input.pos = time_input_pos self._time_input.disabled = False if orientation == "input" else True self._time_input.size = ( [dp(216), dp(62)] if orientation == "input" else [dp(216), dp(72)] ) Clock.schedule_once(self._time_input._update_padding) # Circular selector. if orientation == "input": if self.theme_cls.device_orientation == "portrait": selector_pos = [dp(34), dp(-256)] self._selector.scale_origin = [dp(162), dp(200)] else: selector_pos = [dp(324), dp(-19)] self._selector.scale_origin = [dp(292), dp(109)] elif orientation == "portrait": self._selector.pos = selector_pos = [dp(36), dp(76)] else: self._selector.pos = selector_pos = [dp(304), dp(76)] Animation( pos=selector_pos, scale=0 if orientation == "input" else 1, opacity=0 if orientation == "input" else 1, d=d, t=self.animation_transition, ).start(self._selector) # AM/PM selector. am_pm_pos = ( [dp(252), dp(368)] if orientation == "portrait" else ( [dp(24), dp(126)] if orientation == "landscape" else [dp(252), dp(96)] ) ) am_pm_size = ( [dp(52), dp(80)] if orientation == "portrait" else ( [dp(216), dp(40)] if orientation == "landscape" else [dp(48), dp(70)] ) ) if anim: Animation( pos=am_pm_pos, size=am_pm_size, d=d, t=self.animation_transition, ).start(self._am_pm_selector) else: self._am_pm_selector.pos = am_pm_pos self._am_pm_selector.size = am_pm_size self._am_pm_selector.orientation = ( "horizontal" if orientation == "landscape" else "vertical" ) # MDTimePicker. time_picker_size = ( [dp(328), dp(500)] if orientation == "portrait" else ( [dp(584), dp(368)] if orientation == "landscape" else [dp(324), dp(218)] ) ) if anim: Animation( size=time_picker_size, d=d, t=self.animation_transition, ).start(self) else: self.size = time_picker_size # Minute label. Animation( pos=[dp(144), dp(76)], opacity=1 if orientation == "input" else 0, d=d, t=self.animation_transition, ).start(self._minute_label) # Hour label. Animation( pos=[dp(24), dp(76)], opacity=1 if orientation == "input" else 0, d=d, t=self.animation_transition, ).start(self._hour_label) self._state = orientation self.ids.input_clock_switch.icon = ( "clock-time-four-outline" if orientation == "input" else "keyboard" ) def _set_current_time(self, *args): self.time = self._get_data() def _switch_input(self): self._update_pos_size( self.theme_cls.device_orientation if self._state == "input" else "input", anim=True, )