├── TimePicker-with-24h-support.png ├── myRecipes └── kivymd │ ├── __init__.py │ └── timepicker.patch ├── LICENSE ├── .gitignore ├── README.md └── timepicker.py /TimePicker-with-24h-support.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Babber/KivyMD-TimePicker-with-24h-support/HEAD/TimePicker-with-24h-support.png -------------------------------------------------------------------------------- /myRecipes/kivymd/__init__.py: -------------------------------------------------------------------------------- 1 | from pythonforandroid.recipe import PythonRecipe 2 | 3 | # See the documentation at https://python-for-android.readthedocs.io/en/latest/recipes/ 4 | 5 | class MDTimePickerFix(PythonRecipe): 6 | 7 | site_packages_name = 'kivymd' 8 | version = '041c7af' # master as of 220223 9 | url = 'https://github.com/kivymd/KivyMD/archive/{version}.zip' 10 | depends = ['kivy'] 11 | 12 | call_hostpython_via_targetpython = False # Due to setuptools. 13 | 14 | def prebuild_arch(self, arch): 15 | super().prebuild_arch(arch) 16 | self.apply_patch('timepicker.patch', arch) 17 | 18 | def build_arch(self, arch): 19 | super().build_arch(arch) 20 | 21 | def get_recipe_env(self, arch): 22 | env = super().get_recipe_env(arch) 23 | return env 24 | 25 | def postbuild_arch(self, arch): 26 | super().postbuild_arch(arch) 27 | 28 | recipe = MDTimePickerFix() 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Babber 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # KivyMD TimePicker with 24-hour time support 2 | 3 | According to Wikipedia: 4 | > The [12-hour time convention](https://en.wikipedia.org/wiki/12-hour_clock) is common in several English-speaking nations and former British colonies, as well as a few other countries. ... In most countries, however, the 24-hour clock is the standard system used, especially in writing. ... [This system](https://en.wikipedia.org/wiki/24-hour_clock), as opposed to the 12-hour clock, is the most commonly used time notation in the world today, and is used by the international standard ISO 8601. 5 | 6 | Unfortunately, the KivyMD project removed the option of a 24-hour clock as *unnecessary,* at the same time also referring to some Material Design related reasons, which are rather unclear to me. (See [this thread](https://github.com/kivymd/KivyMD/issues/132#issuecomment-1039161258) for yourself.) To [my enquiry](https://github.com/kivymd/KivyMD/issues/132#issuecomment-1039554915) asking what happens if I implement this feature myself, I was told that they *consciously got rid of the functionality*. This is why I post this outcast feature myself instead of a pull request to the KivyMD repo. If you like this feature of a 24-hour clock or you simply want to show your support for an inclusive TimePicker that offers both options thus making everyone happy, please, consider giving this tiny repo a Star. Who knows, if it gains enough popularity, maybe it could be part of KivyMD one day?.. 7 | 8 | Independently of this design detail, I would like to express my great appreciation to all the [Contributors](https://github.com/kivymd/KivyMD#contributors) of the [KivyMD](https://github.com/kivymd/KivyMD) project, which I find a very useful and valuable extension to Kivy! 9 | 10 |
11 | 12 | ## How to use `timepicker.py` in a desktop environment? 13 | 14 | Releases of this repo are tagged as `v+`. If you use the given KivyMD release or one that is sufficiently similar, just replace `/kivymd/uix/pickers/timepicker/timepicker.py` with the file from here, then... 15 | 16 | ```py 17 | #MDTimePicker.AMPM_or_24h="24h" # to use the 24h clock (the default setting) 18 | #MDTimePicker.AMPM_or_24h="AMPM" # to use the 12h clock 19 | 20 | time_dialog = MDTimePicker() 21 | ``` 22 | 23 |
24 | 25 | ## How to make use of it with [Buildozer](https://github.com/kivy/buildozer) or [python-for-android](https://github.com/kivy/python-for-android/)? 26 | 27 | The patch in the recipe is also based on the same version of the KivyMD master branch as `timepicker.py`, see above. To apply this recipe, follow the steps below: 28 | 29 | * If you don't have it yet, create a directory in your project folder for your own recipes (e.g. `myRecipes`) and download the `kivymd` folder into it. 30 | * You also have to amend your `buildozer.spec` file in three ways: 31 | * `source.exclude_dirs = ..,myRecipes` (not critical, but better so) 32 | * `requirements = ..,kivymd` < whatever you had here before the application of the above recipe (could be e.g. `https://github.com/kivymd/KivyMD/archive/master.zip`), now you have to refer to it with the name of your recipe: `kivymd` 33 | * `p4a.local_recipes = myRecipes` 34 | * Before the next build, either delete the corresponding folders, or just `buildozer android clean` to enforce the new recipe with the patch. 35 | 36 |
37 | 38 | ![TimePicker with 24h support](TimePicker-with-24h-support.png) 39 | -------------------------------------------------------------------------------- /myRecipes/kivymd/timepicker.patch: -------------------------------------------------------------------------------- 1 | --- a/kivymd/uix/pickers/timepicker/timepicker.py 2 | +++ b/kivymd/uix/pickers/timepicker/timepicker.py 3 | @@ -163,7 +163,6 @@ class AmPmSelector(ThemableBehavior, MDBoxLayout, EventDispatcher): 4 | 5 | class TimeInputTextField(MDTextField): 6 | num_type = OptionProperty("hour", options=["hour", "minute"]) 7 | - hour_regx = "^[0-9]$|^0[1-9]$|^1[0-2]$" 8 | minute_regx = "^[0-9]$|^0[0-9]$|^[1-5][0-9]$" 9 | 10 | def __init__(self, **kwargs): 11 | @@ -171,6 +170,7 @@ class TimeInputTextField(MDTextField): 12 | Clock.schedule_once(self.on_text) 13 | self.register_event_type("on_select") 14 | self.bind(text_color=self.setter("hint_text_color_normal")) 15 | + TimeInputTextField.hour_regx = "^[0-9]$|^0[1-9]$|^1[0-2]$" if MDTimePicker.AMPM_or_24h == 'AMPM' else "^[0-9]$|^0[0-9]$|^1[0-9]$|^2[0-3]$" 16 | 17 | def validate_time(self, s): 18 | reg = self.hour_regx if self.num_type == "hour" else self.minute_regx 19 | @@ -189,8 +189,9 @@ class TimeInputTextField(MDTextField): 20 | to somehow make them aligned. 21 | """ 22 | 23 | - if not self.text: 24 | - self.text = " " 25 | + if MDTimePicker.AMPM_or_24h == 'AMPM': # The two lines under the first 'if' are not needed in the '24h' mode. Besides, this saves us (only in the '24h' mode!) from the bug that crashes TimePicker when you delete all digits in the text input field for the hour or the minute. See the bug report at https://github.com/kivymd/KivyMD/issues/966. 26 | + if not self.text: 27 | + self.text = " " 28 | 29 | self._refresh_text(self.text) 30 | max_size = max(self._lines_rects, key=lambda r: r.size[0]).size 31 | @@ -203,15 +204,16 @@ class TimeInputTextField(MDTextField): 32 | 33 | def on_focus(self, *args): 34 | super().on_focus(*args) 35 | - if self.text.strip(): 36 | - if ( 37 | - not self.focus 38 | - and int(self.text) == 0 39 | - and self.num_type == "hour" 40 | - ): 41 | - self.text = "12" 42 | - else: 43 | - self.text = " 12" if self.num_type == "hour" else " 00" 44 | + if MDTimePicker.AMPM_or_24h == 'AMPM': 45 | + if self.text.strip(): 46 | + if ( 47 | + not self.focus 48 | + and int(self.text) == 0 49 | + and self.num_type == "hour" 50 | + ): 51 | + self.text = "12" 52 | + else: 53 | + self.text = " 12" if self.num_type == "hour" else " 00" 54 | 55 | def on_select(self, *args): 56 | pass 57 | @@ -268,7 +270,7 @@ class SelectorLabel(MDLabel): 58 | 59 | 60 | class CircularSelector(MDCircularLayout, EventDispatcher): 61 | - mode = OptionProperty("hour", options=["hour", "minute"]) # and military 62 | + mode = OptionProperty("hour", options=["hour", "minute"]) 63 | text_color = ColorProperty() 64 | selected_hour = StringProperty("12") 65 | selected_minute = StringProperty("0") 66 | @@ -300,22 +302,22 @@ class CircularSelector(MDCircularLayout, EventDispatcher): 67 | 68 | def _update_labels(self, animate=True, *args): 69 | """ 70 | - This method builds the selector based on current mode which currently 71 | - can be hour or minute. 72 | + This method builds the selector based on current mode. 73 | """ 74 | 75 | if self.mode == "hour": 76 | - param = (1, 12) 77 | - self.degree_spacing = 30 78 | - self.start_from = 60 79 | + if MDTimePicker.AMPM_or_24h == "AMPM": 80 | + param = (1, 12) 81 | + self.degree_spacing = 30 82 | + self.start_from = 60 83 | + else: 84 | + param = (0, 23) 85 | + self.degree_spacing = 30 86 | + self.start_from = 90 87 | elif self.mode == "minute": 88 | param = (0, 59, 5) 89 | self.degree_spacing = 6 90 | self.start_from = 90 91 | - elif self.mode == "military": 92 | - param = (1, 24) 93 | - self.degree_spacing = 30 94 | - self.start_from = 90 95 | if animate: 96 | anim = Animation(content_scale=0, t=self.t, d=self.d) 97 | anim.bind(on_complete=lambda *args: self._add_items(*param)) 98 | @@ -431,6 +433,14 @@ class CircularSelector(MDCircularLayout, EventDispatcher): 99 | 100 | 101 | class MDTimePicker(BaseDialogPicker): 102 | + AMPM_or_24h = OptionProperty("24h", options=["24h", "AMPM"]) 103 | + """ 104 | + The time representation to use: `'AMPM'` or `'24h'` 105 | + 106 | + :attr:`AMPM_or_24h` is an :class:`~kivy.properties.OptionProperty` 107 | + and defaults to `'24h'`. 108 | + """ 109 | + 110 | hour = StringProperty("12") 111 | """ 112 | Current hour 113 | @@ -536,9 +546,9 @@ class MDTimePicker(BaseDialogPicker): 114 | ) 115 | self.theme_cls.bind(device_orientation=self._check_orienation) 116 | self.title = "SELECT TIME" 117 | - # default time 118 | - self.set_time(datetime.time(hour=12, minute=0)) 119 | + self.set_time(datetime.time(hour=12, minute=0)) # default time 120 | self._check_orienation() 121 | + MDTimePicker.AMPM_or_24h = self.AMPM_or_24h 122 | 123 | def _get_dial_time(self, instance): 124 | mode = instance.mode 125 | @@ -582,7 +592,8 @@ class MDTimePicker(BaseDialogPicker): 126 | hour = time_obj.hour 127 | minute = time_obj.minute 128 | if hour > 12: 129 | - hour -= 12 130 | + if self.AMPM_or_24h == "AMPM": 131 | + hour -= 12 132 | mode = "pm" 133 | else: 134 | mode = "am" 135 | @@ -602,10 +613,11 @@ class MDTimePicker(BaseDialogPicker): 136 | 137 | def _get_data(self): 138 | try: 139 | - result = datetime.datetime.strptime( 140 | + result = (datetime.datetime.strptime( 141 | f"{int(self.hour):02d}:{int(self.minute):02d} {self.am_pm}", 142 | - "%I:%M %p", 143 | - ).time() 144 | + "%I:%M %p") if self.AMPM_or_24h == "AMPM" else datetime.datetime.strptime( 145 | + f"{int(self.hour):02d}:{int(self.minute):02d}", 146 | + "%H:%M")).time() 147 | return result 148 | except ValueError: 149 | return None # hour is zero 150 | @@ -619,7 +631,7 @@ class MDTimePicker(BaseDialogPicker): 151 | d = self.animation_duration 152 | # time input 153 | time_input_pos = ( 154 | - [dp(24), dp(368)] 155 | + [dp(24) if self.AMPM_or_24h == "AMPM" else dp(56), dp(368)] 156 | if orientation == "portrait" 157 | else ( 158 | [dp(24), dp(178)] 159 | @@ -685,6 +697,8 @@ class MDTimePicker(BaseDialogPicker): 160 | ) 161 | if anim: 162 | Animation( 163 | + scale=1 if self.AMPM_or_24h == "AMPM" else 0, 164 | + opacity=1 if self.AMPM_or_24h == "AMPM" else 0, 165 | pos=am_pm_pos, 166 | size=am_pm_size, 167 | d=d, 168 | @@ -693,6 +707,8 @@ class MDTimePicker(BaseDialogPicker): 169 | else: 170 | self._am_pm_selector.pos = am_pm_pos 171 | self._am_pm_selector.size = am_pm_size 172 | + self._am_pm_selector.scale = 1 if self.AMPM_or_24h == "AMPM" else 0 173 | + self._am_pm_selector.opacity = 1 if self.AMPM_or_24h == "AMPM" else 0 174 | 175 | self._am_pm_selector.orientation = ( 176 | "horizontal" if orientation == "landscape" else "vertical" 177 | -------------------------------------------------------------------------------- /timepicker.py: -------------------------------------------------------------------------------- 1 | """ 2 | Components/TimePicker 3 | ===================== 4 | 5 | .. seealso:: 6 | 7 | `Material Design spec, Time picker `_ 8 | 9 | .. rubric:: Includes time picker. 10 | 11 | .. image:: https://github.com/HeaTTheatR/KivyMD-data/raw/master/gallery/kivymddoc/picker-previous.png 12 | :align: center 13 | 14 | .. warning:: The widget is under testing. Therefore, we would be grateful if 15 | you would let us know about the bugs found. 16 | 17 | .. rubric:: Usage 18 | 19 | .. tabs:: 20 | 21 | .. tab:: Declarative KV style 22 | 23 | .. code-block:: python 24 | 25 | from kivy.lang import Builder 26 | 27 | from kivymd.app import MDApp 28 | from kivymd.uix.pickers import MDTimePicker 29 | 30 | KV = ''' 31 | MDFloatLayout: 32 | 33 | MDRaisedButton: 34 | text: "Open time picker" 35 | pos_hint: {'center_x': .5, 'center_y': .5} 36 | on_release: app.show_time_picker() 37 | ''' 38 | 39 | 40 | class Test(MDApp): 41 | def build(self): 42 | self.theme_cls.theme_style = "Dark" 43 | self.theme_cls.primary_palette = "Orange" 44 | return Builder.load_string(KV) 45 | 46 | def show_time_picker(self): 47 | '''Open time picker dialog.''' 48 | 49 | time_dialog = MDTimePicker() 50 | time_dialog.open() 51 | 52 | 53 | Test().run() 54 | 55 | .. tab:: Declarative python style 56 | 57 | .. code-block:: python 58 | 59 | from kivymd.app import MDApp 60 | from kivymd.uix.button import MDRaisedButton 61 | from kivymd.uix.pickers import MDTimePicker 62 | from kivymd.uix.screen import MDScreen 63 | 64 | 65 | class Test(MDApp): 66 | def build(self): 67 | self.theme_cls.theme_style = "Dark" 68 | self.theme_cls.primary_palette = "Orange" 69 | return ( 70 | MDScreen( 71 | MDRaisedButton( 72 | text="Open time picker", 73 | pos_hint={'center_x': .5, 'center_y': .5}, 74 | on_release=self.show_time_picker, 75 | ) 76 | ) 77 | ) 78 | 79 | def show_time_picker(self, *args): 80 | '''Open time picker dialog.''' 81 | 82 | MDTimePicker().open() 83 | 84 | 85 | Test().run() 86 | 87 | .. image:: https://github.com/HeaTTheatR/KivyMD-data/raw/master/gallery/kivymddoc/MDTimePicker.png 88 | :align: center 89 | 90 | Binding method returning set time 91 | --------------------------------- 92 | 93 | .. code-block:: python 94 | 95 | def show_time_picker(self): 96 | time_dialog = MDTimePicker() 97 | time_dialog.bind(time=self.get_time) 98 | time_dialog.open() 99 | 100 | def get_time(self, instance, time): 101 | ''' 102 | The method returns the set time. 103 | 104 | :type instance: 105 | :type time: 106 | ''' 107 | 108 | return time 109 | 110 | Open time dialog with the specified time 111 | ---------------------------------------- 112 | 113 | Use the :attr:`~MDTimePicker.set_time` method of the 114 | :class:`~MDTimePicker.` class. 115 | 116 | .. code-block:: python 117 | 118 | def show_time_picker(self): 119 | from datetime import datetime 120 | 121 | # Must be a datetime object 122 | previous_time = datetime.strptime("03:20:00", '%H:%M:%S').time() 123 | time_dialog = MDTimePicker() 124 | time_dialog.set_time(previous_time) 125 | time_dialog.open() 126 | 127 | .. note:: For customization of the :class:`~MDTimePicker` class, see the 128 | documentation in the :class:`~kivymd.uix.pickers.datepicker.datepicker.BaseDialogPicker` class. 129 | 130 | .. code-block:: python 131 | 132 | MDTimePicker( 133 | primary_color="brown", 134 | accent_color="red", 135 | text_button_color="white", 136 | ).open() 137 | 138 | .. image:: https://github.com/HeaTTheatR/KivyMD-data/raw/master/gallery/kivymddoc/time-picker-customization.png 139 | :align: center 140 | """ 141 | 142 | __all__ = ("MDTimePicker",) 143 | 144 | import datetime 145 | import os 146 | import re 147 | import time 148 | from typing import List, Union 149 | 150 | from kivy.animation import Animation 151 | from kivy.clock import Clock 152 | from kivy.event import EventDispatcher 153 | from kivy.lang import Builder 154 | from kivy.metrics import dp 155 | from kivy.properties import ( 156 | BooleanProperty, 157 | ColorProperty, 158 | ListProperty, 159 | NumericProperty, 160 | ObjectProperty, 161 | OptionProperty, 162 | StringProperty, 163 | VariableListProperty, 164 | ) 165 | from kivy.uix.behaviors import ButtonBehavior 166 | from kivy.vector import Vector 167 | 168 | from kivymd import uix_path 169 | from kivymd.theming import ThemableBehavior 170 | from kivymd.uix.boxlayout import MDBoxLayout 171 | from kivymd.uix.circularlayout import MDCircularLayout 172 | from kivymd.uix.label import MDLabel 173 | from kivymd.uix.pickers.datepicker import BaseDialogPicker 174 | from kivymd.uix.relativelayout import MDRelativeLayout 175 | from kivymd.uix.textfield import MDTextField 176 | 177 | with open( 178 | os.path.join(uix_path, "pickers", "timepicker", "timepicker.kv"), 179 | encoding="utf-8", 180 | ) as kv_file: 181 | Builder.load_string(kv_file.read()) 182 | 183 | 184 | class AmPmSelectorLabel(ButtonBehavior, MDLabel): 185 | pass 186 | 187 | 188 | class AmPmSelector(ThemableBehavior, MDBoxLayout): 189 | border_radius = NumericProperty() 190 | border_color = ColorProperty() 191 | bg_color = ColorProperty() 192 | bg_color_active = ColorProperty() 193 | border_width = NumericProperty() 194 | am = ObjectProperty() 195 | am = ObjectProperty() 196 | owner = ObjectProperty() 197 | text_color = ColorProperty() 198 | selected = StringProperty() 199 | 200 | _am_bg_color = ColorProperty() 201 | _pm_bg_color = ColorProperty() 202 | 203 | def __init__(self, **kwargs): 204 | super().__init__(**kwargs) 205 | self.bind(selected=self._upadte_color) 206 | Clock.schedule_once(self._upadte_color) 207 | 208 | def _upadte_color(self, *args): 209 | bg_color = ( 210 | self.owner.accent_color 211 | if self.owner.accent_color 212 | else self.bg_color_active 213 | ) 214 | if self.selected == "am": 215 | self._am_bg_color = bg_color 216 | self._pm_bg_color = ( 217 | self.owner.primary_color 218 | if self.owner.accent_color 219 | else self.bg_color 220 | ) 221 | elif self.selected == "pm": 222 | self._am_bg_color = ( 223 | self.owner.primary_color 224 | if self.owner.accent_color 225 | else self.bg_color 226 | ) 227 | self._pm_bg_color = bg_color 228 | 229 | 230 | class TimeInputTextField(MDTextField): 231 | num_type = OptionProperty("hour", options=["hour", "minute"]) 232 | #hour_regx = "^[0-9]$|^0[1-9]$|^1[0-2]$" 233 | minute_regx = "^[0-9]$|^0[0-9]$|^[1-5][0-9]$" 234 | 235 | def __init__(self, *args, **kwargs): 236 | super().__init__(*args, **kwargs) 237 | Clock.schedule_once(self.set_text) 238 | self.register_event_type("on_select") 239 | self.bind(text_color_focus=self.setter("hint_text_color_normal")) 240 | TimeInputTextField.hour_regx = "^[0-9]$|^0[1-9]$|^1[0-2]$" if MDTimePicker.AMPM_or_24h == 'AMPM' else "^[0-9]$|^0[0-9]$|^1[0-9]$|^2[0-3]$" 241 | 242 | def validate_time(self, text) -> Union[None, re.Match]: 243 | reg = self.hour_regx if self.num_type == "hour" else self.minute_regx 244 | return re.match(reg, text) 245 | 246 | def insert_text(self, text, from_undo=False): 247 | strip_text = self.text.strip() 248 | current_string = "".join([strip_text, text]) 249 | if not self.validate_time(current_string): 250 | text = "" 251 | return super().insert_text(text, from_undo=from_undo) 252 | 253 | def set_text(self, *args) -> None: 254 | """ 255 | Texts should be center aligned. Now we are setting the padding of text 256 | to somehow make them aligned. 257 | """ 258 | 259 | def set_text(*args): 260 | if MDTimePicker.AMPM_or_24h == 'AMPM': # The two lines under the first 'if' are not needed in the '24h' mode. Besides, this saves us (only in the '24h' mode!) from the bug that crashes TimePicker when you delete all digits in the text input field for the hour or the minute. See the bug report at https://github.com/kivymd/KivyMD/issues/966. 261 | if not self.text: 262 | self.text = " " 263 | 264 | self._refresh_text(self.text) 265 | max_size = max(self._lines_rects, key=lambda r: r.size[0]).size 266 | dx = (self.width - max_size[0]) / 2.0 267 | dy = (self.height - max_size[1]) / 2.0 268 | self.padding = [dx, dy, dx, dy] 269 | 270 | if len(self.text) > 1: 271 | self.text = self.text.replace(" ", "") 272 | 273 | Clock.schedule_once(set_text) 274 | 275 | def on_focus(self, *args) -> None: 276 | super().on_focus(*args) 277 | if MDTimePicker.AMPM_or_24h == 'AMPM': 278 | if self.text.strip(): 279 | if ( 280 | not self.focus 281 | and int(self.text) == 0 282 | and self.num_type == "hour" 283 | ): 284 | self.text = "12" 285 | else: 286 | self.text = " 12" if self.num_type == "hour" else " 00" 287 | 288 | def on_select(self, *args) -> None: 289 | pass 290 | 291 | def on_touch_down(self, touch): 292 | if self.collide_point(*touch.pos): 293 | self.dispatch("on_select") 294 | super().on_touch_down(touch) 295 | 296 | 297 | class TimeInput(MDRelativeLayout): 298 | """Implements two text fields for displaying and entering a time value.""" 299 | 300 | bg_color = ColorProperty() 301 | bg_color_active = ColorProperty() 302 | text_color = ColorProperty() 303 | disabled = BooleanProperty(True) 304 | minute_radius = ListProperty([0, 0, 0, 0]) 305 | hour_radius = ListProperty([0, 0, 0, 0]) 306 | state = StringProperty("hour") 307 | 308 | _hour = ObjectProperty() 309 | _minute = ObjectProperty() 310 | 311 | def __init__(self, **kwargs): 312 | super().__init__(**kwargs) 313 | self.register_event_type("on_time_input") 314 | self.register_event_type("on_hour_select") 315 | self.register_event_type("on_minute_select") 316 | 317 | def set_time(self, time_list) -> None: 318 | hour, minute = time_list 319 | self._hour.text = hour 320 | self._minute.text = minute 321 | 322 | def get_time(self) -> List[str]: 323 | hour = self._hour.text.strip() 324 | minute = self._minute.text.strip() 325 | return [hour, minute] 326 | 327 | def on_time_input(self, *args) -> None: 328 | pass 329 | 330 | def on_minute_select(self, *args) -> None: 331 | pass 332 | 333 | def on_hour_select(self, *args) -> None: 334 | pass 335 | 336 | def _update_padding(self, *args): 337 | self._hour.set_text() 338 | self._minute.set_text() 339 | 340 | 341 | class SelectorLabel(MDLabel): 342 | pass 343 | 344 | 345 | class CircularSelector(MDCircularLayout, EventDispatcher): 346 | """Implements clock face display.""" 347 | 348 | mode = OptionProperty("hour", options=["hour", "minute"]) # and military 349 | text_color = ColorProperty() 350 | selected_hour = StringProperty("12") 351 | selected_minute = StringProperty("0") 352 | selector_size = NumericProperty("48dp") 353 | selector_pos = ListProperty([0, 0]) 354 | selector_color = ColorProperty() 355 | bg_color = ColorProperty() 356 | font_name = StringProperty() 357 | scale = NumericProperty(1) 358 | content_scale = NumericProperty(1) 359 | t = StringProperty("out_quad") 360 | d = NumericProperty(0.2) 361 | scale_origin = ListProperty([100, 100]) 362 | 363 | _centers_pos = ListProperty() 364 | 365 | def __init__(self, **kwargs): 366 | super().__init__(**kwargs) 367 | self.bind( 368 | mode=self._update_labels, 369 | selected_hour=self.update_time, 370 | selected_minute=self.update_time, 371 | ) 372 | Clock.schedule_once(lambda x: self._update_labels(animate=False)) 373 | self.register_event_type("on_selector_change") 374 | 375 | def do_layout(self, *largs, **kwargs): 376 | self.update_time() 377 | return super().do_layout(*largs, **kwargs) 378 | 379 | def set_selector(self, selected) -> bool: 380 | """Sets the selector's position towards the given text.""" 381 | 382 | widget = None 383 | for wid in self.children: 384 | wid.text_color = self.text_color 385 | if wid.text == selected: 386 | widget = wid 387 | if not widget: 388 | return False 389 | self.selector_pos = widget.center 390 | widget.text_color = [1, 1, 1, 1] 391 | self.dispatch("on_selector_change") 392 | return True 393 | 394 | def set_time(self, selected) -> None: 395 | if self.mode == "hour": 396 | self.selected_hour = selected 397 | elif self.mode == "minute": 398 | self.selected_minute = selected 399 | 400 | def update_time(self, *args) -> None: 401 | if self.mode == "hour": 402 | self.set_selector(self.selected_hour) 403 | elif self.mode == "minute": 404 | self.set_selector(self.selected_minute) 405 | 406 | def get_selected(self) -> str: 407 | return self.selected 408 | 409 | def switch_mode(self, mode) -> None: 410 | if mode != self.mode: 411 | self.mode = mode 412 | 413 | def on_touch_down(self, touch): 414 | if self.collide_point(*touch.pos): 415 | touch.grab(self) 416 | closest_wid = self._get_closest_widget(touch.pos) 417 | self.set_time(closest_wid.text) 418 | return True 419 | 420 | def on_touch_move(self, touch): 421 | if touch.grab_current == self: 422 | closest_wid = self._get_closest_widget(touch.pos) 423 | self.set_time(closest_wid.text) 424 | 425 | def on_touch_up(self, touch): 426 | if touch.grab_current is self: 427 | touch.ungrab(self) 428 | return True 429 | 430 | def on_selector_change(self, *args): 431 | pass 432 | 433 | def _update_labels(self, animate=True, *args): 434 | """ 435 | This method builds the selector based on current mode which currently 436 | can be hour or minute. 437 | """ 438 | 439 | if self.mode == "hour": 440 | if MDTimePicker.AMPM_or_24h == "AMPM": 441 | param = (1, 12) 442 | self.degree_spacing = 30 443 | self.start_from = 60 444 | else: 445 | param = (0, 23) 446 | self.degree_spacing = 30 447 | self.start_from = 90 448 | elif self.mode == "minute": 449 | param = (0, 59, 5) 450 | self.degree_spacing = 6 451 | self.start_from = 90 452 | if animate: 453 | anim = Animation(content_scale=0, t=self.t, d=self.d) 454 | anim.bind(on_complete=lambda *args: self._add_items(*param)) 455 | anim.start(self) 456 | else: 457 | self._add_items(*param) 458 | 459 | def _add_items(self, start, end, step=1): 460 | """ 461 | Adds all number in range `[start, end + 1]` to the circular layout with 462 | the specified step. Step means that all widgets will be added to layout 463 | but sets the opacity for skipped widgets to `0` because we are using 464 | the label's text as a reference to the selected number so we have to 465 | add these to layout. 466 | """ 467 | 468 | self.clear_widgets() 469 | i = 0 470 | for x in range(start, end + 1): 471 | label = SelectorLabel( 472 | text=f"{x}", 473 | ) 474 | if i % step != 0: 475 | label.opacity = 0 476 | self.bind( 477 | text_color=label.setter("text_color"), 478 | font_name=label.setter("font_name"), 479 | ) 480 | self.add_widget(label) 481 | i += 1 482 | Clock.schedule_once(self.update_time) 483 | Clock.schedule_once(self._get_centers, 0.1) 484 | anim = Animation(content_scale=1, t=self.t, d=self.d) 485 | anim.start(self) 486 | 487 | def _get_centers(self, *args): 488 | """ 489 | Returns a list of all center. we use this for positioning the selector 490 | indicator. 491 | """ 492 | 493 | self._centers_pos = [] 494 | for child in self.children: 495 | self._centers_pos.append(child.center) 496 | 497 | def _get_closest_widget(self, pos): 498 | """ 499 | Returns the nearest widget to the given position. we use this to create 500 | the magnetic effect. 501 | """ 502 | 503 | distance = [Vector(pos).distance(point) for point in self._centers_pos] 504 | if not distance: 505 | return False 506 | index = distance.index(min(distance)) 507 | return self.children[index] 508 | 509 | 510 | class MDTimePicker(BaseDialogPicker): 511 | AMPM_or_24h = OptionProperty("24h", options=["24h", "AMPM"]) 512 | """ 513 | The time representation to use: `'AMPM'` or `'24h'` 514 | 515 | :attr:`AMPM_or_24h` is an :class:`~kivy.properties.OptionProperty` 516 | and defaults to `'24h'`. 517 | """ 518 | 519 | hour = StringProperty("12") 520 | """ 521 | Current hour. 522 | 523 | :attr:`hour` is an :class:`~kivy.properties.StringProperty` 524 | and defaults to `'12'`. 525 | """ 526 | 527 | minute = StringProperty("0") 528 | """ 529 | Current minute. 530 | 531 | :attr:`minute` is an :class:`~kivy.properties.StringProperty` 532 | and defaults to `0`. 533 | """ 534 | 535 | minute_radius = VariableListProperty(dp(5), length=4) 536 | """ 537 | Radius of the minute input field. 538 | 539 | .. image:: https://github.com/HeaTTheatR/KivyMD-data/raw/master/gallery/kivymddoc/time-picker-minute-radius.png 540 | :align: center 541 | 542 | :attr:`minute_radius` is an :class:`~kivy.properties.ListProperty` 543 | and defaults to `[dp(5), dp(5), dp(5), dp(5)]`. 544 | """ 545 | 546 | hour_radius = VariableListProperty(dp(5), length=4) 547 | """ 548 | Radius of the hour input field. 549 | 550 | .. image:: https://github.com/HeaTTheatR/KivyMD-data/raw/master/gallery/kivymddoc/time-picker-hour-radius.png 551 | :align: center 552 | 553 | :attr:`hour_radius` is an :class:`~kivy.properties.ListProperty` 554 | and defaults to `[dp(5), dp(5), dp(5), dp(5)]`. 555 | """ 556 | 557 | am_pm_radius = NumericProperty("5dp") 558 | """ 559 | Radius of the AM/PM selector. 560 | 561 | .. image:: https://github.com/HeaTTheatR/KivyMD-data/raw/master/gallery/kivymddoc/time-picker-am-pm-radius.png 562 | :align: center 563 | 564 | :attr:`am_pm_radius` is an :class:`~kivy.properties.NumericProperty` 565 | and defaults to `dp(5)`. 566 | """ 567 | 568 | am_pm_border_width = NumericProperty("1dp") 569 | """ 570 | Width of the AM/PM selector's borders. 571 | 572 | .. image:: https://github.com/HeaTTheatR/KivyMD-data/raw/master/gallery/kivymddoc/time-picker-am-pm-border-width.png 573 | :align: center 574 | 575 | :attr:`am_pm_border_width` is an :class:`~kivy.properties.NumericProperty` 576 | and defaults to `dp(1)`. 577 | """ 578 | 579 | am_pm = OptionProperty("am", options=["am", "pm"]) 580 | """ 581 | Current AM/PM mode. 582 | 583 | :attr:`am_pm` is an :class:`~kivy.properties.OptionProperty` 584 | and defaults to `'am'`. 585 | """ 586 | 587 | animation_duration = NumericProperty(0.3) 588 | """ 589 | Duration of the animations. 590 | 591 | :attr:`animation_duration` is an :class:`~kivy.properties.NumericProperty` 592 | and defaults to `0.2`. 593 | """ 594 | 595 | animation_transition = StringProperty("out_quad") 596 | """ 597 | Transition type of the animations. 598 | 599 | :attr:`animation_transition` is an :class:`~kivy.properties.StringProperty` 600 | and defaults to `'out_quad'`. 601 | """ 602 | 603 | time = ObjectProperty(allownone=True) 604 | """ 605 | Returns the current time object. 606 | 607 | :attr:`time` is an :class:`~kivy.properties.ObjectProperty` 608 | and defaults to `None`. 609 | """ 610 | 611 | _state = StringProperty() 612 | _selector = ObjectProperty() 613 | _time_input = ObjectProperty() 614 | _am_pm_selector = ObjectProperty() 615 | _hour_label = ObjectProperty() 616 | _minute_label = ObjectProperty() 617 | _anim_playing = BooleanProperty(False) 618 | 619 | def __init__(self, **kwargs): 620 | super().__init__(**kwargs) 621 | self.bind( 622 | hour=self._set_current_time, 623 | minute=self._set_current_time, 624 | am_pm=self._set_current_time, 625 | ) 626 | self.theme_cls.bind(device_orientation=self._check_orienation) 627 | if self.title == "SELECT DATE": 628 | self.title = "SELECT TIME" 629 | self.set_time(datetime.time(hour=12, minute=0)) # default time 630 | self._check_orienation() 631 | MDTimePicker.AMPM_or_24h = self.AMPM_or_24h 632 | 633 | def set_time(self, time_obj) -> None: 634 | """Manually set time dialog with the specified time.""" 635 | 636 | hour = time_obj.hour 637 | minute = time_obj.minute 638 | if hour > 12: 639 | if self.AMPM_or_24h == "AMPM": 640 | hour -= 12 641 | mode = "pm" 642 | else: 643 | mode = "am" 644 | hour = str(hour) 645 | minute = str(minute) 646 | self._set_time_input(hour, minute) 647 | self._set_dial_time(hour, minute) 648 | self._set_am_pm(mode) 649 | 650 | def get_state(self) -> str: 651 | """ 652 | Returns the current state of TimePicker. 653 | Can be one of `portrait`, `landscape` or `input`. 654 | """ 655 | 656 | return self._state 657 | 658 | def _get_dial_time(self, instance): 659 | mode = instance.mode 660 | if mode == "hour": 661 | self.hour = instance.selected_hour 662 | elif mode == "minute": 663 | self.minute = instance.selected_minute 664 | else: 665 | raise Exception("invalid mode for MDTimePicker: " % mode) 666 | self._set_time_input(self.hour, self.minute) 667 | 668 | def _set_dial_time(self, hour, minute): 669 | self._selector.selected_minute = minute 670 | self._selector.selected_hour = hour 671 | 672 | def _get_time_input(self, hour, minute): 673 | if hour: 674 | self.hour = f"{int(hour):01d}" 675 | if minute: 676 | self.minute = f"{int(minute):01d}" 677 | self._set_dial_time(self.hour, self.minute) 678 | 679 | def _set_time_input(self, hour, minute): 680 | hour = f"{int(hour):02d}" 681 | minute = f"{int(minute):02d}" 682 | if self._state != "input": 683 | self._time_input.set_time([hour, minute]) 684 | 685 | def _get_am_pm(self, selected): 686 | self.am_pm = selected 687 | 688 | def _set_am_pm(self, selected: str) -> None: 689 | """Used by set_time() to manually set the mode to "am" or "pm".""" 690 | self.am_pm = selected 691 | self._am_pm_selector.mode = self.am_pm 692 | self._am_pm_selector.selected = self.am_pm 693 | 694 | def _get_data(self): 695 | try: 696 | if time.strftime("%p"): 697 | result = (datetime.datetime.strptime( 698 | f"{int(self.hour):02d}:{int(self.minute):02d} {self.am_pm}", 699 | "%I:%M %p") if self.AMPM_or_24h == "AMPM" else datetime.datetime.strptime( 700 | f"{int(self.hour):02d}:{int(self.minute):02d}", 701 | "%H:%M")).time() 702 | else: 703 | result = (datetime.datetime.strptime( 704 | f"{int(self.hour):02d}:{int(self.minute):02d}", 705 | "%I:%M") if self.AMPM_or_24h == "AMPM" else datetime.datetime.strptime( 706 | f"{int(self.hour):02d}:{int(self.minute):02d}", 707 | "%H:%M" 708 | )).time() 709 | return result 710 | except ValueError: 711 | return None # hour is zero 712 | 713 | def _check_orienation(self, *args, do_anim=False): 714 | orientation = self.theme_cls.device_orientation 715 | if self._state != "input" and orientation != self._state: 716 | self._update_pos_size(orientation, anim=do_anim) 717 | 718 | def _update_pos_size(self, orientation, anim=False): 719 | d = self.animation_duration 720 | # time input 721 | time_input_pos = ( 722 | [dp(24) if self.AMPM_or_24h == "AMPM" else dp(56), dp(368)] 723 | if orientation == "portrait" 724 | else ( 725 | [dp(24), dp(178)] 726 | if orientation == "landscape" 727 | else [dp(24), dp(96)] 728 | ) 729 | ) 730 | if anim: 731 | _time_input = Animation( 732 | pos=time_input_pos, 733 | d=d, 734 | t=self.animation_transition, # 80 - 8, 735 | ) 736 | _time_input.start(self._time_input) 737 | else: 738 | self._time_input.pos = time_input_pos 739 | 740 | self._time_input.disabled = False if orientation == "input" else True 741 | self._time_input.size = ( 742 | [dp(216), dp(62)] if orientation == "input" else [dp(216), dp(72)] 743 | ) 744 | Clock.schedule_once(self._time_input._update_padding) 745 | 746 | # Circular selector. 747 | if orientation == "input": 748 | if self.theme_cls.device_orientation == "portrait": 749 | selector_pos = [dp(34), dp(-256)] 750 | self._selector.scale_origin = [dp(162), dp(200)] 751 | else: 752 | selector_pos = [dp(324), dp(-19)] 753 | self._selector.scale_origin = [dp(292), dp(109)] 754 | elif orientation == "portrait": 755 | self._selector.pos = selector_pos = [dp(36), dp(76)] 756 | else: 757 | self._selector.pos = selector_pos = [dp(304), dp(76)] 758 | 759 | Animation( 760 | pos=selector_pos, 761 | scale=0 if orientation == "input" else 1, 762 | opacity=0 if orientation == "input" else 1, 763 | d=d, 764 | t=self.animation_transition, 765 | ).start(self._selector) 766 | 767 | # AM/PM selector. 768 | am_pm_pos = ( 769 | [dp(252), dp(368)] 770 | if orientation == "portrait" 771 | else ( 772 | [dp(24), dp(126)] 773 | if orientation == "landscape" 774 | else [dp(252), dp(96)] 775 | ) 776 | ) 777 | am_pm_size = ( 778 | [dp(52), dp(80)] 779 | if orientation == "portrait" 780 | else ( 781 | [dp(216), dp(40)] 782 | if orientation == "landscape" 783 | else [dp(48), dp(70)] 784 | ) 785 | ) 786 | if anim: 787 | Animation( 788 | scale=1 if self.AMPM_or_24h == "AMPM" else 0, 789 | opacity=1 if self.AMPM_or_24h == "AMPM" else 0, 790 | pos=am_pm_pos, 791 | size=am_pm_size, 792 | d=d, 793 | t=self.animation_transition, 794 | ).start(self._am_pm_selector) 795 | else: 796 | self._am_pm_selector.pos = am_pm_pos 797 | self._am_pm_selector.size = am_pm_size 798 | self._am_pm_selector.scale = 1 if self.AMPM_or_24h == "AMPM" else 0 799 | self._am_pm_selector.opacity = 1 if self.AMPM_or_24h == "AMPM" else 0 800 | 801 | self._am_pm_selector.orientation = ( 802 | "horizontal" if orientation == "landscape" else "vertical" 803 | ) 804 | 805 | # MDTimePicker. 806 | time_picker_size = ( 807 | [dp(328), dp(500)] 808 | if orientation == "portrait" 809 | else ( 810 | [dp(584), dp(368)] 811 | if orientation == "landscape" 812 | else [dp(324), dp(218)] 813 | ) 814 | ) 815 | if anim: 816 | Animation( 817 | size=time_picker_size, 818 | d=d, 819 | t=self.animation_transition, 820 | ).start(self) 821 | else: 822 | self.size = time_picker_size 823 | 824 | # Minute label. 825 | Animation( 826 | pos=[dp(144), dp(76)], 827 | opacity=1 if orientation == "input" else 0, 828 | d=d, 829 | t=self.animation_transition, 830 | ).start(self._minute_label) 831 | 832 | # Hour label. 833 | Animation( 834 | pos=[dp(24), dp(76)], 835 | opacity=1 if orientation == "input" else 0, 836 | d=d, 837 | t=self.animation_transition, 838 | ).start(self._hour_label) 839 | 840 | self._state = orientation 841 | self.ids.input_clock_switch.icon = ( 842 | "clock-time-four-outline" if orientation == "input" else "keyboard" 843 | ) 844 | 845 | def _set_current_time(self, *args): 846 | self.time = self._get_data() 847 | 848 | def _switch_input(self): 849 | self._update_pos_size( 850 | self.theme_cls.device_orientation 851 | if self._state == "input" 852 | else "input", 853 | anim=True, 854 | ) 855 | --------------------------------------------------------------------------------