├── 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 | 
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 |
--------------------------------------------------------------------------------