#!/usr/bin/env python3 r""" ===================================================================================== Frigcal: calendars made simple, portable, and private A standalone and Python-coded Frigcal app for both Android and PC platforms. Provided as an app for Android and macOS and executables for Windows and Linux. Version: 4.0.0, January 2026 Copyright © 2026 quixotely.com. All rights reserved. License and terms of use: Frigcal is provided freely but as is and with no warranties of any kind, and its maker is not liable for any damages caused by running this program. By downloading, installing, or otherwise accessing Frigcal, you are granted a license to run, view, and modify it, but you may not post, sell, or redistribute it. By using Frigcal, you are agreeing to these terms of use. See also file terms-of-use.txt in the app or its source code folder. Origins and tools: Some code here derives from either the "PC-Phone USB Sync" PPUS app also available at quixotely.com or the legacy Frigcal 3.0 program available at learning-python.com. Notably new here are: • The Android Storage Access Framework (SAF) for calendar-folder selection Instead of PPUS's POSIX via All Files Access and Frigcal 3'0 POSIX • A navigation drawer with screens and themes, via KivyMD Instead of PPUS's top-of-window tabs and Frigcal 3.0's border buttons • Swipe gestures for month-screen month/year navigation Instead of Frigcal 3.0's navigation buttons and keypresses • App dark/light color theme and colorized event-category overscore bars Instead of Frigcal 3.0's many configurable colors The legacy Frigcal 3.0 uses tkinter for its GUI and runs as source code in the Pydroid 3 app on Android. To support a standalone Android app, its code was radically rewritten to use both the Kivy and KivyMD GUI frameworks, as well as Android's SAF storage framework for calendar files. For more on tools used by this app, see about-template.txt and the app's About screen. Code structure: This file defines the app itself, the top-level navigation drawer, and app-wide tools. Files *gui.py implement GUI components, and common.py collects shared names. main.kv is the nav drawer, allpopups.kv and allscreens.kv are popups and screens, and common.kv is shared UI defs. ===================================================================================== """ #==================================================================================== # Imports and globals (TBD: some may be unused) #==================================================================================== # moot tries... # # set keyboard mode before other Kivy imports? # from kivy.config import Config # Config.set('kivy', 'keyboard_mode', 'dock') # # set softinput mode before other Kivy imports? # from kivy.core.window import Window # Window.softinput_mode = 'below_target' # app modules - and prestart_pc_tweaks.py run from common import * # collector module used in >1 files # instance in App, for user persistent configs from settings import AppSettings # instance in App, for hang-free POSIX IO (TBD: used?) from hangless import FileSystemLocalNoHang # instances in App, for folder-code bifurcation from storage import CalendarsStorage_PCs, CalendarsStorage_Android # instance in App, for month-screen widgets and callbacks from monthgui import MonthGUI # (avoids circular from with Factory) # instance in App, for search-screen widgets and callbacks from searchgui import SearchGUI # (avoids circular from with Factory) # instance in App, for settings-screen widgets and callbacks from settingsgui import SettingsGUI # icalendar event and calendar object tools from icsfiletools import * import icsfiletools # to set global # plus prestart_pc_tweaks imported by common.py for timing # one Eventdata(), global to support cut/copy in one window and paste in another CopiedEvent = None # main data structures: parsed and indexed file data, used but not changed here from storage import CalendarsTable # {icsfilename: icalendar.Calendar()} from storage import EventsTable # {Edate(): {uid: EventData()} ] from storage import CalendarsDirty # {icsfilename: Boolean] # data structure classes in EventsTable from storage import Edate, EventData # linux distribution info for About (3rd-party module, shipped with app) if RunningOnLinux: try: from distro import distro except: print('Required and included distro package not present') sys.exit(1) KV = """ GUI definition: moved to file main.kv and its subfiles """ #==================================================================================== # Classes linked to .kv Kivy-lang files #==================================================================================== # Coding hindsight: instead of coding the MDScreen subclasses below, # screens might have been coded in the *gui.py files, with both any # methods defined below and methods now coded in the *gui.py standalone # classes. OTOH, that may cause widget-mod issues at __init__ time, # and the current coding was chosen on purpose to minimize reliance # on the very subtle and implicit Python/Kivy-lang linkage in Kivy. class MainWindow(MDBoxLayout): """ ------------------------------------------------------- Root window This is self.root in App, and the first non <> rule in the .kv file. A boxLayout is required at GUI root for Android 15+ edge-to-edge .padding, and a Py class is needed for screen-wide gestures. Per nesting in .kv file, contains MDScreen + screens + menu button/items. ------------------------------------------------------- """ pass class MonthScreen(MDScreen): """ ------------------------------------------------------- Swipe and double-tap gestures Touch callbacks registered in the Month screen only. ------------------------------------------------------- """ # minimum distance for swipe instead of tap or event short-scroll long_swipe_threshold = dp(200) # minimum distance for swipe on month label at top: no scolls conflict short_swipe_threshhold = dp(40) def on_touch_down(self, touch): """ Start of touch gesture Note that the Month screen must be open in these gestures' callbacks because the events are only active for that screen. Reopening it and tweaking the menu is pointess but harmless. See also: - KivyMD TouchBehavior for double taps - Kivy Carousel or gestures4kivy.CommonGestures for swipes Swipes can also be coded with grabs + touch.dx TBD: apply this to month grid, not whole screen, else a True return breaks this screen's hamburger-menu button? This was made moot by the following update. UPDATE: this now: - Uses a longer swipe distance to minimize conflict with scrolls of events on days - Handles swipes on the month label at screen top specially with a shorter swipe distance because there is no conflict with event scrolls in this context and less space to swipe up/down - does not handle debouncing of medium-speed double taps: event edit, add, and list dialogs in monthgui.py avoid double opens """ app = MDApp.get_running_app() # for app menu-callback handlers monthlabel = self.ids.screenlabel # for short swipes on month name if self.collide_point(*touch.pos): # is the touch within this widget? if touch.is_double_tap: # that means entire Month screen trace('Double Tap') if app.pending_tap_event: app.pending_tap_event.cancel() # cancel day or event single tap app.pending_tap_event = None app.on_menu_today(app.menuMonthItem) # two taps, Month screen is open return True # True=consume and end the touch event else: monthtap = monthlabel.collide_point(*touch.pos) self.swipe_threshold = \ self.short_swipe_threshhold if monthtap else \ self.long_swipe_threshold touch.ud['initial_pos'] = touch.pos #return True # True here breaks hamburger button return super().on_touch_down(touch) # pass unhandled touches to parent def on_touch_up(self, touch): """ End of touch gesture """ app = MDApp.get_running_app() if 'initial_pos' in touch.ud: initial_x, initial_y = touch.ud['initial_pos'] final_x, final_y = touch.pos dx = final_x - initial_x dy = final_y - initial_y if abs(dx) > self.swipe_threshold and abs(dx) > abs(dy): if dx > 0: trace('Swiped Right') app.on_menu_priormonth(app.menuMonthItem) # to the right else: trace('Swiped Left') app.on_menu_nextmonth(app.menuMonthItem) # to the left return True # end up event elif abs(dy) > self.swipe_threshold and abs(dy) > abs(dx): if dy > 0: trace('Swiped Up') app.on_menu_nextyear(app.menuMonthItem) # to the top else: trace('Swiped Down') app.on_menu_prioryear(app.menuMonthItem) # to the bottom return True # end up event return super().on_touch_up(touch) # propagate to other widgets # --------------------- # Screens in nav drawer # --------------------- #class MonthScreen(MDScreen): # plus monthgui.py methods # defined above for swipes class SettingsScreen(MDScreen): # plus settingsgui.py methods pass class SearchScreen(MDScreen): # plus searchgui.py methods pass class HelpScreen(MDScreen): # text management coded here pass class AboutScreen(MDScreen): # text management coded here pass class InfoDialog(BoxLayout): """ ------------------------------------------------------- The info-message dialog. Also defined with <> instance rule in .kv. self argument implied in .kv file: root=instance. Class and its properties _must_ by defined in .py. ------------------------------------------------------- """ message = StringProperty() # passed here as args => root properties oncancel = ObjectProperty(None) # refs bound callback in Main, run in .kv class ConfirmDialog(BoxLayout): """ ------------------------------------------------------- All in .kv, but must declare here too ------------------------------------------------------- """ message = StringProperty() onyes = ObjectProperty(None) onno = ObjectProperty(None) class BusyDialog(BoxLayout): """ ------------------------------------------------------- The busy-wait dialog. Also defined with <> instance rule in .kv. self argument implied in .kv file: root=instance. Class and its properties _must_ by defined in .py. ------------------------------------------------------- """ message = StringProperty() # passed here as args => root properties onokay = ObjectProperty(None) # when enabled after thread exit class NewCalendarDialog(BoxLayout): """ ------------------------------------------------------- All in .kv, but must declare here too ------------------------------------------------------- """ message = StringProperty() onadd = ObjectProperty(None) oncancel = ObjectProperty(None) '''CUT class GoToDateDialog(BoxLayout): """ ------------------------------------------------------- All in .kv, but must declare here too Now uses a canned KivyMD dialog: see allpopups.kv ------------------------------------------------------- """ message = StringProperty() onfocus = ObjectProperty(None) ongoto = ObjectProperty(None) oncancel = ObjectProperty(None) CUT''' from kivy.base import EventLoop class MDTextField_FixSelectPasteBubble(MDTextField): # # Touch control # """ [FC] Override this method in the Kivy TextInput superclass to force the SelectAll/Paste bubble opened on longpress to appear near the touch location. Else, the bubble is opened way too far ahead and off screen for larger text notes. This may reflect Kivy layout assumptions, but it's clearly a Kivy bug; GUI toolkits shouldn't require such heroics. Kivy TextInput is a mix-in superclass of KivyMD MDTextField: class TextInput(FocusBehavior, Widget): ... class MDTextField(..., TextInput, ...): ... Crucial on phones: no other way to paste, sans an on-screen keyboard with widget (Samsung) or control keys (Hacker's). The Cut/Copy/Paste bubble on text selects seems to work OK, but having to add+select dummy text to get a Paste is bogus. """ def long_touch(self, dt): self._long_touch_ev = None if self._selection_to == self._selection_from: # original Kivy code """ pos = self.to_local(*self._touch_down.pos, relative=False) self._show_cut_copy_paste( pos, EventLoop.window, mode='paste') """ trace('TextInput bubble tweak') pos = self._touch_down.pos self._show_cut_copy_paste( pos, EventLoop.window, mode='paste', pos_in_window=True) class ColorPickDialog(BoxLayout): """ ------------------------------------------------------- All in .kv, but must declare here too; TBD: used in FC? ------------------------------------------------------- """ onpick = ObjectProperty(None) oncancel = ObjectProperty(None) class ConfigCheckbox(CheckBox): """ ------------------------------------------------------- TBD: from PPUS, used in FC? (uses KivyMD widgets) Ref in .kv, def in .py: need to set color per patform [r, g, b, a] for image tinting - too dim on macos only Default is [1, 1, 1, 1], which works well elsewhere ------------------------------------------------------- """ color = [1, 3, 5, 4] if RunningOnMacOS else [1, 1, 1, 1] class TextInputNoSelect(MDLabel): ##TextInput, MDTextField """ ------------------------------------------------------- TBD: from PPUS, used in FC? (mostly uses MD Labels) See note at Help in allscreens.kv: no great options. Kivy 2.1.0 has a nasty bug which makes selection handles linger on in the GUI on Android - both after popup dismiss, and after tab switch. This is an issue only for _readonly_ text, as handles go away on either keyboard minimize or tab switch; chooser paths and log text are readonly. The final workaround overrides selection methods in the TextInput class here (it didn't work in .kv) to disable selections in full. This makes the earlier fixes moot, and brings an end to all the troubles. FC UPDATE: later versions of Kivy's TextInput, and by inheritance, KivyMD's MDTextField, have "use_handles" and "use_bubble" properties, which, if set to False, disable selection handles and bubbles without the method overrides here. See allpopups.kv and common.kv for usage in FC. ------------------------------------------------------- """ muiltiline = True def on_double_tap(self): pass # trace('on_double_tap\n') def on_triple_tap(self): pass # trace('on_triple_tap\n') def on_quad_touch(self): pass # trace('on_quad_touch\n') def long_touch(self, dt): pass # trace('long_touch\n') # See also: BorderedDayLayout and other dynamic widgets in monthgui.py #==================================================================================== # The App class (a.k.a. "app" in .kv), with this program's non-screen logic in FC #==================================================================================== class Frigcal(MDApp): """ ---------------------------------------------------------- Handles app build, startup, and close, along with all main-menu actions and common ops. Screen-specific logic is mostly split off to separate *gui.py files, which get a link back the App instance for ops and other screens. Builds main root widget defined in ./main.kv, in build(). Loads file by explicit name (alts: class name or string). Could build GUI manually, but avoids add_widget(), etc. In all cases, 'root' refers to the top-level main widget. See common.kv for use of dynamic_font_size and font_size, and the related variables for font name. TL;DR: KivyMD overrides them for MD, so they must be reset after creates. UPDATE: font-name setting was dropped, rationale elsewhere. NB: MDApp is patched trivially to avoid a brief KivyMD icon on startup, only on macOS hosts used for PyInstaller builds. ---------------------------------------------------------- """ # class globals # bound to Widget font_size in .kv: changes update all (non-KivyMD) widgets dynamic_font_size = NumericProperty(FONT_SIZE_DEFAULT) # set before build() # NOT bound to Widget font_name in .kv: breaks menu icons and hamburger button #dynamic_font_name = StringProperty(FONT_NAME_DEFAULT) # CUT (see common.kv) # till overridden by self attribute on PCs in storage.py folderchooser_open = False # TBD: a frigcal3 feature that may not work in phones' cramped displays mutiple_month_windows = False # to cancel single-tap handlers on double-tap in MonthScreen pending_tap_event = None # for splitting text into multiple Labels textboxlabels = {} # {layout-widget: [label-widgets]} #==================================================================================== # App: GUI Build #==================================================================================== def build(self): """ ------------------------------------------------------------------- Make GUI, run before App.on_start(). The return value here becomes self.root for the App instance. This method is optional if the .kv file is auto loaded by name. ------------------------------------------------------------------- """ traces('@' * 80, 'app.build') # call out in android logcat # from settings, set for use by this and other classes self.settings = AppSettings(self) # do here for theme+bubbles self.theme_cls.theme_style = self.settings.colortheme # now persistent self.theme_cls.primary_palette = 'Blue' # must be before kv load #self.theme_cls.theme_style = 'Dark' # or 'Light' (no 'Auto') #self.theme_cls.theme_style_switch_animation = True # broken in kivymd 1.1.1! #self.theme_cls.primary_hue = '800' # required before search-screen built, dialogs are later self.editbubbles = self.settings.editbubbles # build gui from kvlang rules # name frigcal.kv would be auto, but eibti #tree = Builder.load_string(KV) # if an embedded string here, can do > once # kvlang files are relative to '.' set prestart_pc_tweaks: source-code dir, # or, per build's --add-data, macOS .app or temp unzip for PyInstaller exes kvlangfile = 'main.kv' tree = Builder.load_file(kvlangfile) # this plus its includes # nav-drawer clickables (sans child-widget scans) self.menuAllItems = [ tree.ids[id] for id in ('month', 'search', 'goto', 'save', 'new', 'theme', 'settings', 'help', 'about', 'today', 'nextmo', 'priormo', 'nextyr', 'prioryr')] # screen switches self.menuSwitchScreenItems = [ tree.ids[id] for id in ('month', 'search', 'settings', 'help', 'about')] # month-screen openers self.menuOpenMonthItems = [ tree.ids[id] for id in ('goto', 'today', 'nextmo', 'priormo', 'nextyr', 'prioryr')] # no screen change self.menuNeutralItems = [ tree.ids[id] for id in ('new', 'save', 'theme')] # menu state-transition info self.menuMonthItem = tree.ids.month # for forced screen switches self.menuPriorSelect = tree.ids.month # for shading and click skips # opens with first screen in mgr: Month self.menuMonthItem.selected = True self.menuMonthItem.selected_color = self.manual_theme_color_sel() # proto: force days and month label ##self.fill_days_grid(tree) trace('end app.build') return tree # becomes App's self.root #==================================================================================== # App: main menu and its actions #==================================================================================== def toggle_menu(self): """ ------------------------------------------------------------------- Show or hide the navigation drawer and its nested menu. ------------------------------------------------------------------- """ trace('toggle_menu') self.root.ids['nav_drawer'].set_state('toggle') def switch_to_screen(self, switchto, root=None, label=None): """ ------------------------------------------------------------------- Change main window's screen to the one named switchto in .kv. Noth that the first sreen in manager (month) is opened first. ------------------------------------------------------------------- """ trace('switch_to_screen') self.root.ids.navscreenmgr.current = switchto #------------------------------------------------------------------- # Shared manual theme colors, for kivymd colors fail in nav menu. # KivyMD 1.1.1 also botched colors on theme switches after screen # switches: KivyMD 1.2.0 handles ok but requires Kivy 2.3.1 (gulp). # Also used in the .kv by a class that initializes colors @startup. # See also themeForPopup() for manual popup dialogs theme tweaks. #------------------------------------------------------------------- def manual_theme_color_fg(self): return 'white' if self.theme_cls.theme_style == 'Dark' else 'black' def manual_theme_color_bg(self): return '#212121' if self.theme_cls.theme_style == 'Dark' else 'white' def manual_theme_color_sel(self): return 'gray' def manage_menu_post_click(self, clicked, toggle=True): """ ------------------------------------------------------------------- Go manual on colors and selections for navigation-drawer items. Else, multiple menu items are selected and wrongly colored after switching themes - so badly that icons/text are invisible (argh!). The only workaround that sufficed for all cases is a full colors reset for all menu items after each menu selection, along with: - Graying clicked navSwitchScreenItems that imply a screen switch - Graying Month for clicked navOpenMonthItems that imply a Month open - Graying navPriorSelect's for clicks on navNeutralItems to restore Graying is used to indicate the current screen. This doesn't fully disable items because changing item.disabled triggers another Kivy or KivyMD bug that changes the color of unrelated widgets, including screen text and the hamburger icon, making them invisible (argh!). The graying steps could be done in each item's callback instead of using item lists, but centralized here. This originally used a gross child-widget walk before adopting item lists made in build(): ''' menuitems = self.root.ids['nav_drawer_menu'].children[0].children for child in menuitems: if hasattr(child, 'text') and hasattr(child, 'icon'): child.selected = False ''' Skip menu close for nav items to keep menu open for mutiple clicks, skip clicks on items whose screen is already open, and postpone menu-item color changes with Clock till menu state is complete. Subtle: swipes invoke nav menu ops, which don't toggle the menu (for multiple taps) but work for swipes too (no menu, Month open). Also: KivyMD 1.1.1's theme_style_switch_animation broken (argh!!!). ------------------------------------------------------------------- """ # close nav drawer menu, except for nav items if toggle: self.toggle_menu() def reset_menu_items(clicked): # reset menu-item colors/selection for item in self.menuAllItems: #item.disabled = False item.text_color = self.manual_theme_color_fg() item.selected_color = self.manual_theme_color_fg() item.icon_color = self.manual_theme_color_fg() item.bg_color = self.manual_theme_color_bg() # set or restore menu-item selection if clicked in self.menuSwitchScreenItems: #clicked.disabled = True # kivymd 1.1.1 code bug clicked.selected = True clicked.selected_color = self.manual_theme_color_sel() self.menuPriorSelect = clicked elif clicked in self.menuOpenMonthItems: self.menuMonthItem.selected = True self.menuMonthItem.selected_color = self.manual_theme_color_sel() self.menuPriorSelect = self.menuMonthItem elif clicked in self.menuNeutralItems: trace('prior select') self.menuPriorSelect.selected = True self.menuPriorSelect.selected_color = self.manual_theme_color_sel() # do-later required, else state flux botches some colors Clock.schedule_once(lambda dt: reset_menu_items(clicked)) # ============== # MENU CALLBACKS # ============== # ---- # MAIN # ---- """ # now done in storage.py after calendars ask + load thread def fill_days_grid(self, tree=None): # prototype; must go deeper for ids in screens ...trimmed... """ def on_menu_month(self, instance): if instance == self.menuPriorSelect: # ignore if already open trace('skip') else: # screen switch self.switch_to_screen('month') self.manage_menu_post_click(instance) def on_menu_search(self, instance): if instance == self.menuPriorSelect: # ignore if already open trace('skip') else: # screen switch self.switch_to_screen('search') self.manage_menu_post_click(instance) def on_goto_date(self, instance, value, date_range): # Used for both Ok in Go To popup and item pick in # Search screen; value is the target date as a py # datetime.date object, other arguments are unused # date-picker artifacts, date picker is auto-closed; # mod month-screen content date = value.strftime('%m/%d/%Y') # MM/DD/YYYY self.monthgui.viewdate.setdate(date) self.refill_month_widgets() # mod screen+menu after Ok, not Cancel; instance != goto self.switch_to_screen('month') self.manage_menu_post_click(self.root.ids.goto, toggle=False) def on_menu_goto(self, instance): # popup + switch to Month screen # FC 3.0-'s goto was a persistent widget at top of month. # FC 4.0 uses a modal popup dialog opened in main menu instead. # Now uses a canned KivyMD 1.2.0 picker dialog instead of custom. trace('menu goto') viewdate = self.monthgui.viewdate config = dict( month=viewdate.month(), # open on view date, else today and +navs day=viewdate.day(), year=viewdate.year(), firstweekday=6) # start weeks Sunday, else 0=Monday != Month # toggle now for date popup, but can't set menu to Month until Ok; # pass nav-neutral 'new' to reset prior shade lost on toggle (hack!) #self.manage_menu_post_click(instance) self.manage_menu_post_click(self.root.ids.new, toggle=True) # open kivymd date picker dialog datedialog = MDDatePicker(**config) # in 1.2.0, always a popup, input=dd/mm/yyyy datedialog.helper_text = '' # else always shows "Wrong date," weirdly datedialog.bind(on_save=self.on_goto_date) # bind required (for properties?) datedialog.open() # prior custom-dialog code cut here: see allpopups.kv # ----- # TOOLS # ----- def on_menu_save(self, instance): # no popup or screen switch, but may show folder-ask and busy-wait popups # There is a gray boundary use case if the calendar folder # has become inaccessible and we must ask for a new one now, # but the best we can do is to save changes to a new folder # here because loading new calendar files would wipe out the # very changes that the user is trying to save. # # After saving in this case, the GUI won't quite reflect the # new folder, but this is so rare as to be likely impossible. # The user would have to delete or rename the calendar folder # or remove the app's permission to it... while the app is # running and after calendars have been loaded. # # So, this can plausibly be marked up to a user procedural error, # and the user can sort out the aftereffects. Still, for safety, # run_with_* now augments an info_message to alert the user if this # bizarre use case is detected. For more info, see storage.py's # ask_calendars_folder_info(). trace('save') if not any(CalendarsDirty[icsfilename] for icsfilename in CalendarsTable): # skip popups, but tell user too else silence self.info_message('No changed calendars to save', usetoast=True) else: Clock.schedule_once( lambda dt: self.storage.run_with_calendars_folder( self.storage.backup_and_save_all_changed_ics_files) # see Subtle there ) self.manage_menu_post_click(instance) def on_menu_new(self, instance): # popup only, no screen switch # FC 3.0-'s new was a cmdline script that wrote canned text to # a .ics file and expected it to be loaded on the next GUI launch. # FC 4.0's New instead adds an in-memory calendar only and relies # on user Save to write it to .ics file. 4.0 also avoids a bogus # sole event by adding it only on Save iff required, and avoids a # default calendar by expecting New for initially empty folders # and requiring at least one calendar (e.g. by New) before events # can be added. trace('new') message = ( '[b]Enter new calendar name[/b]' '\n\n' 'Below, please enter the name of the new calendar you wish to add, with ' 'or without the ".ics" extension of its filename.' '\n\n' 'Add events to your new calendar normally by selecting it in the ' 'event dialog, and use the main menu\'s Save to create the new ' 'calendar\'s ".ics" file.') def add_calendar(prompter): # prompter: root from .kv self.dismiss_popup(prompter) # also in enclosing scope icsfilename = prompter.ids.newcalname.text if not icsfilename: # add with no filename self.info_message( 'Calendar name empty: skipped', usetoast=True) return if not icsfilename.endswith('.ics'): icsfilename += '.ics' if icsfilename in CalendarsTable: # else backup/save may overwrite original! self.info_message( 'Calendar already exists: skipped', usetoast=True) return CalendarsTable[icsfilename] = new_vcalendar() # make empty in-memory calendar CalendarsDirty[icsfilename] = True # force first write on Save self.info_message( 'Calendar added: Save for file', usetoast=True) prompter = NewCalendarDialog( message=message, onadd=add_calendar, oncancel=self.dismiss_popup) popup = Popup( title='New Calendar', content=prompter, auto_dismiss=False, # ignore taps outside size_hint=(0.9, 0.8), # phones wide, e-2-e short **self.themeForPopup()) # fg/bg colors if 'light' self.popupStack.push(popup) self.popupStack.open() # show top Popup with Dialog self.manage_menu_post_click(instance) def on_theme(self, instance, toggle=True): # no popup or screen switch # toggle is False for Settings screen Theme trace('theme') if self.theme_cls.theme_style == 'Dark': self.theme_cls.theme_style = 'Light' else: self.theme_cls.theme_style = 'Dark' self.monthgui.shade_current_day() # re-colorize current day self.set_system_bars_fg_color() # toggle bars' color on Android self.set_system_bars_bg_color() # no-op for transparent bars # ad-hoc theme fixes: some already-built widgets do not auto-adjust; # themes seem awfully buggy in kivymd 1.2.0; have they improved yet? self.searchgui.fix_widgets_for_theme() self.settingsgui.fix_widgets_for_theme() # also update theme name in the Settings screen # moot for Settings mods but not for main-menu Theme # .settings table is not updated till Save to file newtheme = self.theme_cls.theme_style self.settingsgui.screen.ids.colortheme.text = newtheme self.manage_menu_post_click(instance, toggle) def on_menu_settings(self, instance): if instance == self.menuPriorSelect: # ignore if already open trace('skip') else: # screen switch self.switch_to_screen('settings') self.manage_menu_post_click(instance) # ---- # INFO # ---- """CUT def split_label_text_halves(self, text): # NO LONGER USED: generalized into set_split_label_text(). # Split text into halves for two labels to avoid blank text. # A gross workaround for Label size limits: see Help in # allscreens.kv, and notes at on_pause()/on_resume ahead. # Also unknown if must stage text for Label like TextInput. lines = text.split('\n') half = len(lines) // 2 lines1, lines2 = lines[:half], lines[half:] return '\n'.join(lines1), '\n'.join(lines2) # was used like this: helpscreen = self.root.ids.navscreenmgr.get_screen('help') helpwidget1 = helpscreen.ids.helptext_half1 helpwidget2 = helpscreen.ids.helptext_half2 helpwidget1.text, helpwidget2.text = \ self.split_label_text(self.stage_help_message) CUT""" def set_split_label_text(self, text, labelbox, reuse=True): # Display large text in multiple Labels for size limits. # Avoids blank text initially (but not after pause/resume). # - labelbox: a layout in a ScrollView for the text Labels # - reuse: set text of existing Labels for speed, else rebuilds # Splits on lines b/c words/chars imply complex partial lines. # Originally used halves, generalized for more+smaller labels. maxlinesperlabel = 20 # FC lines (labels): Help=94 (5), About+TOU=75 (4) if labelbox not in self.textboxlabels: # first build reuse = False labelslist = self.textboxlabels[labelbox] = [] # grow shared mutable elif reuse: # retain priors labelslist = self.textboxlabels[labelbox] # mod text in-place else: # rebuild labels priorlabels = self.textboxlabels[labelbox] # clear and regrow for priorlabel in priorlabels: labelbox.remove_widget(priorlabel) # or priorlabel.parent labelslist = self.textboxlabels[labelbox] = [] lines = text.split('\n') numlabels = math.ceil(len(lines) / maxlinesperlabel) nlabel = 0 verify = [] while lines: chunklist, lines = lines[:maxlinesperlabel], lines[maxlinesperlabel:] chunktext = '\n'.join(chunklist) or ' ' verify.append(chunktext) nlabel += 1 if reuse: # prior labels present labelslist[nlabel - 1].text = chunktext # reset label's text else: # make/fill new label nextlabel = Factory.LabelTextDisplay( # class def in .kv file text=chunktext, # empty str makes too tall padding=((dp(10), dp(10), dp(10), 0) if nlabel == 1 else (dp(10), 0, dp(10), 0) if nlabel < numlabels else (dp(10), 0, dp(10), dp(10)) # left, top, right, bottom )) labelbox.add_widget(nextlabel) labelslist.append(nextlabel) # bind font size+name _after_ creation else kivmd overrides per MD nextlabel.font_size = self.dynamic_font_size #nextlabel.font_name = self.dynamic_font_name # CUT trace(f'text split across {nlabel} Labels') # this will only fail at dev/testing time: app info is not dynamic # ('\n'.join(verify) == text) false if last label is for empty line assert (len(text) == sum(len(line) for line in verify) # label chunks text + (nlabel - 1) # '\n' between labels - int(verify and verify[-1] == ' ') # last is ' ' if empty ), 'text-split sanity test failed' def set_help_text(self, dt, reuse=True): # run by on_menu_help (tap) and on_resume (scroll glitch) # replaces [H] headers now, so retained for later changes self.help_text_set = True helpscreen = self.root.ids.navscreenmgr.get_screen('help') helptext = self.headerize_text_markup(self.stage_help_message) # mod [H]s helpbox = helpscreen.ids.helptextbox self.set_split_label_text(helptext, helpbox, reuse) def set_about_text(self, dt, reuse=True): # run by on_menu_about (tap) and on_resume (scroll glitch) # replaces [H] headers now, so retained for later changes self.about_text_set = True aboutscreen = self.root.ids.navscreenmgr.get_screen('about') abouttext = self.headerize_text_markup(self.stage_about_message) # mod [H]s aboutbox = aboutscreen.ids.abouttextbox self.set_split_label_text(abouttext, aboutbox, reuse) def on_menu_help(self, instance): if instance == self.menuPriorSelect: # ignore if already open trace('skip help switch') else: # screen switch, skip fill if already set # TBD: still need to stage text for fill lag? if not getattr(self, 'help_text_set', False): Clock.schedule_once(self.set_help_text) self.switch_to_screen('help') self.manage_menu_post_click(instance) def on_menu_about(self, instance): if instance == self.menuPriorSelect: # ignore if already open trace('skip about sitch') else: # screen switch, skip fill if already set # TBD: still need to stage text for fill lag? if not getattr(self, 'about_text_set', False): Clock.schedule_once(self.set_about_text) self.switch_to_screen('about') self.manage_menu_post_click(instance) # --- # NAV # --- def refill_month_widgets(self): self.monthgui.fill_days() # clears prior daynums self.monthgui.fill_events() # clears prior events def on_menu_today(self, instance): # switch to Month screen trace('today') self.monthgui.viewdate.settoday() self.refill_month_widgets() self.switch_to_screen('month') # moot if run by gesture self.manage_menu_post_click(instance, toggle=False) # keep menu open if open def on_menu_nextmonth(self, instance): # switch to Month screen trace('nextmonth') if not self.mutiple_month_windows: # tbd self.monthgui.viewdate.setnextmonth() # move just this window self.refill_month_widgets() else: for window in OpenMonthWindows: # else all open windows move window.viewdate.setnextmonth() # move this window window.refill_month_widgets() self.switch_to_screen('month') self.manage_menu_post_click(instance, toggle=False) def on_menu_priormonth(self, instance): # switch to Month screen trace('priormonth') if not self.mutiple_month_windows: self.monthgui.viewdate.setprevmonth() self.refill_month_widgets() else: for window in OpenMonthWindows: window.viewdate.setprevmonth() window.refill_month_widgets() self.switch_to_screen('month') self.manage_menu_post_click(instance, toggle=False) def on_menu_nextyear(self, instance): # switch to Month screen trace('nextyear') if not self.mutiple_month_windows: self.monthgui.viewdate.setnextyear() self.refill_month_widgets() else: for window in OpenMonthWindows: window.viewdate.setnextyear() window.refill_month_widgets() self.switch_to_screen('month') self.manage_menu_post_click(instance, toggle=False) def on_menu_prioryear(self, instance): # switch to Month screen trace('prioryear') if not self.mutiple_month_windows: self.monthgui.viewdate.setprevyear() self.refill_month_widgets() else: for window in OpenMonthWindows: window.viewdate.setprevyear() window.refill_month_widgets() self.switch_to_screen('month') self.manage_menu_post_click(instance, toggle=False) #==================================================================================== # App: startup code, run after App.build() GUI construction #==================================================================================== def on_start(self): """ ------------------------------------------------------------------- Run by Kivy App.run() after initialization - after build() has built the GUI, but before the app has started running. self.root is a preset link to the GUI's top-level instance, which is returned by App.build() (or made auto if no build()) [not used much in FC because most code in is in App, not GUI]. ------------------------------------------------------------------- """ super().on_start() traces('~' * 80, 'app.on_start') # call out in android logcat root = self.root # ------- # PRELIMS # ------- # allow Popups to nest self.popupStack = PopupStack() # from hangless, set in kivy chooser on its create (TBD: used?) self.fileSystemLocalNoHang = FileSystemLocalNoHang(self) # now done earlier in build() for theme # self.settings = AppSettings(self) # propagate non-user-changeable cross-file global (yuck) icsfiletools.retainLiteralBackslashes = self.settings.retainLiteralBackslashes # create month-screen widgets+callbacks once on startup self.monthgui = MonthGUI(self) # init calendar days for 'today' in case user taps "x" in folder ask self.monthgui.fill_days() # search-screen methods, factored to file searchscreen = self.root.ids.navscreenmgr.get_screen('search') self.searchgui = SearchGUI(self, searchscreen) # settings-screen methods, factored to file settingsscreen = self.root.ids.navscreenmgr.get_screen('settings') self.settingsgui = SettingsGUI(self, settingsscreen) # init Settings fields from self.settings: explicit access, not properties self.settingsgui.fill_widgets_from_settings() # tweak gui+configs for platforms, etc. (after settings loaded) self.startup_gui_and_config_tweaks() # --------- # CALENDARS # --------- # make platform-specific interface to calendars folder maxbackups = self.settings.maxbackups self.storage = ( CalendarsStorage_Android(self, maxbackups) if RunningOnAndroid else CalendarsStorage_PCs(self, maxbackups) ) # make suggested default calendars folder if needed on PCs - only. # POSIX/pathname is ok for PCs, but not for Android, which does not # allow shared-storage or Documents/ access sans user permission and # has no other good options. See storage.default_calendar_folder_path(). if not RunningOnAndroid: homepath = self.storage.default_calendar_folder_path() self.defcalspath = osjoin(homepath, CALENDARS_FOLDER_DEFAULT_NAME) try: if not os.path.exists(self.defcalspath): os.makedirs(self.defcalspath) except: trace('Cannot make suggested calendars folder:', self.defcalspath) # plan calendar load, which will trigger a folder ask when runnum == 1 or no access # but don't ask/load unless on_start successful and not until TOU screen dismissed # load_all_ics_files also fills days/events for month: must do on load thread exit def after_terms_of_use(): Clock.schedule_once( lambda dt: self.storage.run_with_calendars_folder( self.storage.load_all_ics_files # refills gui on tread exit )) # ------ # SPLASH # ------ # close splash screen for Windows and Linux frozen executables now: # the app window has been opened; must do this _before_ missing-files # error popup (and others) ahead, else splash screen covers it; if hasattr(sys, 'frozen') and (RunningOnWindows or RunningOnLinux): prestart_pc_tweaks.close_pc_exe_splash_screen() # ------ # WINDOW # ------ # on android, pan iff needed so input target is above onscreen keyboard; # '' (no pan: default), 'pan' (cpver bottom half), 'resize' (doesn't work # with SDL2 on Android), or 'below_target' (scroll window so target text # field isjust above keyboard); the latter is generally best, but doesn't # handle multiline text field that grows on input: see allpopups.kv; Window.softinput_mode = 'below_target' # catch Back button: ask user to save if any calendars changed; # also ignore Back/Escape if any dialog open: Back too easy on android Window.bind(on_keyboard=self.catch_back_button) # warn user if tries to close app when any calendars have changed; # tbd: called on 'back' when app foreground?, but NOT on recents swipe? Window.bind(on_request_close=self.on_request_close) # this is not called for SDL2 on Android - bind to keyboard height changes # Window.bind(keyboard_height=lambda *pargs: trace('kb height:', pargs)) # # LATER RETRY: unusable for fixed Note size for autosroll on cursor: # there is no keyboard_height callback for onscreen-keyboard show/hide, # only Android insets callbacks that ignore os-keyboard's size as coded # [only nav/status bar heights are pulled by the e-2-e insets callback, # though kb height is available in callback as the .bottom of Type.ime() # and might be used to set a fixed size for Note = (root - buttons - ime=0), # but could not find a way to make Note size fixed in the .kv code (TBD)] """ def on_keyboard_height(instance, height): trace('on_keyboard_height:', height) Window.bind(keyboard_height=on_keyboard_height) """ # -------- # PC ICONS # -------- # pcs seem to need help on these beyond pyinstaller build # the .ico doesn't work on Windows with Kivy (cause unknown) # *SEE ALSO* prestart_pc_tweaks.py's macOS Dock-icon workaround if not RunningOnAndroid: self.title = APPNAME # TBD: unused? pciconname = ('Frigcal1024.png' if RunningOnWindows else # not .ico 'frigcal.icns' if RunningOnMacOS else 'frigcal.gif') pciconpath = osjoin('icons', pciconname) self.icon = pciconpath # rounded-corners version Window.icon = pciconpath # else kivy icon displayed # ------------ # EDGE-TO-EDGE # ------------ # Adapted from PPUS 1.4.0. Run before any window makers on Android # to register an insets callback handler that pads the main window # as needed for e-2-e display mode mandated when targeting Android 15+. # Handles status bar, navigation bar (3-button and gesture), and hidden # camera cutouts. Must also work around padding issues for a KivyMD # hamburger button and navigation drawer in small-screen landscape # (caution: button reposition here uses constants also in main.kv). # Other requirements: # - Make MainWindow root a BoxLayout or similar, for .padding property # - Make system-bar insets initially black in strings.xml (via # buildozer.spec's apptheme) to match the Kivy splashscreen # - Change system-bar bg colors to transparent where possible, so # the window's bg color set by KivyMD extends below them and # auto-updates on app-theme toggles # - Change system-bar fg colors per KivyMD app theme both here and # on Theme toggles, ignoring device theme # - Some API calls are absent on older Androids: punt on e-2-e and # transparent and force system bar colors per app theme on 10, # and let system bars go black despite app theme on 9-. # This gnarly code is still needed in Kivy 2.3.1 (and KivyMD 1.2.0). # When built with this Kivy and KivyMD for target=Android 16 (api 36) # and run on Android 15+, insets are _not_ automatically padded, and # status and nav bars cover top/bottom of app's screen (really, the # app stretches into them). It was not just Kivy 2.1.0 used by PPUS. # To handle e-2-e, must either redesign GUI, apply this code and its # other requirements, or build for Android 14 and sideloading only and # skip both e-2-e and Play drama (though an Android 16 build posted on # Play today gets 2/3 years updates/availability; Android 14 gets 0/1). # A temp optout is available if target Android 15 but goes away on 16. # This uses setDecorFitsSystemWindows(False) to enable e-2-e portably; # setNavigationBarContrastEnforced(False) to avoid a scrim on the # 3-button navbar, so it's fully transparent; and both XML code in # styles.xml and Java API calls to set status- and nav-bars colors. # Some tools are unavailable on older Androids, hence the cutoff here. # See set_system_bars_bg_color() for the version-specific bar coloring. # With this, all MDScreens work, all Popups are ok, folderchooser is # moot (SAF), and all Androids 8..16 are reasonable. Has same unrelated # Samsung rightways-rotate and tap-in-splitscreen glitches as PPUS, # and Android 16 also required predictive-back optout manifest setting. # Android suggests WindowCompat.enableEdgeToEdge(window) in androidx.core # 1.17, but this made no difference here for b/w compat and still leaves # the navbar translucent and uses device (not app) theme for bar colors: # - developer.android.com/develop/ui/views/layout/edge-to-edge # - developer.android.com/develop/ui/views/layout/edge-to-edge-manually # Currently unused: the height of the onscreen keyoard is also available # in callback as the .bottom of Type.ime() value (for Note fixed size?). # Naturally, this is useful only on Android (Windows touchscreens differ). if RunningOnAndroid and self.get_android_version() == 10: # minimize nesting # Skip e-2-e and force stat/nav bar colors per app theme # Avoids b/w compat issues, including missing api calls trace('skipping edge-to-edge') # really just 10 now self.set_system_bars_bg_color() # black=>app theme on 10- self.set_system_bars_fg_color() # !app theme on all if RunningOnAndroid and self.get_android_version() >= 11: # skip on older androids # Enable e-2-e on next frame and catch insets callback # In callback, pad window for insets, fix btn+navdrawer trace('applying edge-to-edge') self.using_edge_to_edge = True # (moot?) for chooser height @run_on_ui_thread def go_edge_to_edge(dt): # nested ops must be ui thread trace('going edge-to-edge') # Get top view of kivy activity activity = self.get_android_activity() # $=static nested class AndrRId = autoclass('android.R$id') rootview = activity.getWindow().getDecorView().findViewById(AndrRId.content) # Enable e-2-e mode for b/w compat with older androids WindowCompat = autoclass('androidx.core.view.WindowCompat') window = activity.getWindow() WindowCompat.setDecorFitsSystemWindows(window, False) # must be in main ui thread? #WindowCompat.enableEdgeToEdge(window) # same effect in this context # Make navbar truly transparent, no scrim window.setNavigationBarContrastEnforced(False) # must be in main ui thread # Set initial system-bar colors for app theme, with e-2-e self.set_system_bars_bg_color() # black=>transparent on andr11+ self.set_system_bars_fg_color() # also run on main-menu Theme toggles # Define insets callback function def onApplyWindowInsets(view, insets): # Get overlay regions sizes Type = autoclass('androidx.core.view.WindowInsetsCompat$Type') # not android.view.WindowInsets avoid = Type.systemBars() | Type.displayCutout() select = insets.getInsets(avoid) # Pad kivy main window for insets padding = (select.left, select.top, select.right, select.bottom) # kivy order, four possible sides self.root.padding = padding # pad kivy main window for insets # Fix hbbutton and navdrawer in small-screen landscape if select.left or select.right: def fix_hbbutton_after_main_resized(dt): # reposition hamburger button for navbar in small-screen landscape # else offsceeen (left navbar) or on navbar (right navbar) as coded in .kv hbbutton = self.root.ids.hbbutton startpos = self.root.width - (hbbutton.width + dp(8)) # as in .kv (caution) hbbutton.x = (startpos - select.left) - select.right # one select is 0 # manually close navdrawer (tried do_layout() disable/reenable, toggle_menu()*2) # else part of navdrawer exposed on left navbar in small-screen landscape if select.left: self.root.ids.nav_drawer.set_state('closed') # wait until main-window insets padding applied Clock.schedule_once(fix_hbbutton_after_main_resized, 0.25) trace('window insets callback:', padding) return insets # or WindowInsets.CONSUMED (moot) # Register callback to android java api self.saveinsetslistener = onApplyWindowInsets # required: save reference in py rootview.setOnApplyWindowInsetsListener(onApplyWindowInsets) # Force insets update @startup (java: rootview.post(Runnable)) rootview.requestApplyInsets() # must be in main ui thread # Run all in main UI thread after other on_start() ui ops Clock.schedule_once(go_edge_to_edge) # ------ # VITALS # ------ # warn the user if supporting runtime files are absent in CWD: # user likely moved exe out of unzip folder (in docs, but...); # subtle: also works if run as source from another dir, not as # exe in folder, thanks to os.chdir() in prestart_pc_tweaks.py; # POSIX/filepaths OK on Android: files in app-install folder # UPDATE: this is likely moot in FC. Unlike PPUS, FC's PyInstaller # exes on Windows and Linux ship just an exe plus user-only tools; # their runtime items are all in the temp sys_MEIPASS unzip folder # to which the app chdir()s on startup for relative access, so they # won't be moved by users. FC's macOS app's vitals are nested in the # .app bundle but the exe also there is unlikely to be moved; users # also seem unlikely to move main.py out of the source-code package; # and the Android app's bits are fully off-limits. Still... vitals = (HELP_FILE, ABOUT_FILE, TERMS_OF_USE_FILE) #vitals+= (FRIGCAL_PATH, 'frigcal-anim.gif') # not .png # TBD #vitals+= ('frigcal-pc',) if not RunningOnAndroid else () if any(not osexists(v) for v in vitals): self.info_message( '[b]Fatal error: missing app files[/b]' '\n\n' 'This app cannot run, because it cannot find one ' 'or more of its supporting files. Did you move the ' 'executable without its folder? That doesn\'t work.' '\n\n' 'The app will close itself to avoid exceptions. Please ' 'ensure that the app install folder is complete, and see ' 'the User Guide\'s App-Packages coverage for more info.', usetoast=False, nextop=self.shutdown_app) return # show message and die on Okay # ----------- # RUN COUNTER # ----------- # get+incr run counter, ensure vitals # POSIX/filepaths OK on Android too: file in app-private folder # not used for trial version in FC, but may be other (dialog tweaks?) try: self.runnum = self.get_and_inc_run_counter() except: self.info_message( '[b]Fatal error: cannot write admin files[/b]' '\n\n' 'This app cannot run because it does not have permission ' 'to write its admin files. On Windows, this is often caused ' 'by unzipping the package in C:\\Program Files; please use ' 'any other location. In other cases, please ensure that the ' 'app\'s data folder is available and allows reads and writes.' '\n\n' 'The app will close itself to avoid exceptions. Please ' 'see the User Guide\'s App-Packages coverage for more info.', usetoast=False, nextop=self.shutdown_app) return # show message and die on Okay # ---------- # HELP+ABOUT # ---------- # queue text for help and about screen, but don't set in widgets # till later and defer with Clock then, else lag in its display; # TBD: is this still required for FC labels vs PPUS's textinputs? # POSIX/filepaths OK on Android: files in app-install folder # CAUTION: tou_text2 is also used ahead for welcome screen, and # text markup here is also in Help/About .txt and a storage info. # UPDATE: this redundancy was reduced by headerize_text_markup(). # load terms-of-use file, for About and popup tou_file = open(TERMS_OF_USE_FILE, 'r', encoding='utf8') tou_text = tou_file.read() tou_file.close() # text displayed on request, headerized later on fill with open(HELP_FILE, 'r', encoding='utf8') as help_file: self.stage_help_message = help_file.read() with open(ABOUT_FILE, 'r', encoding='utf8') as about_file: platname = ('Android' if RunningOnAndroid else 'Windows' if RunningOnWindows else # no cigwin: a windows exe 'macOS' if RunningOnMacOS else 'Linux' if RunningOnLinux else '(Unknown)') try: if RunningOnAndroid: platname += f' {int(self.get_android_version())}' # 16 elif RunningOnWindows: platname += f' {platform.win32_ver()[0]}' # 11 elif RunningOnMacOS: platname += f' {platform.mac_ver()[0]}' # 15.6.1 elif RunningOnLinux: platname += f' ({distro.name()} {distro.version_parts()[0]})' # (Ubuntu 24) except: pass # punt - not worth reporting # (ppus) add underlines to uppers # tou_text2 = ''.join([line + '\n' if not line.isupper() else # line + '\n' + '=' * len(line) + '\n' # for line in tou_text.splitlines()]) # change all-upper lines to underscores with uppers per simplistic titlecase def titlecase(string): lowers = ['of', 'and', 'or', 'to'] # partial return ' '.join(word.lower() if word.lower() in lowers else word.capitalize() for word in string.split(' ')) # kivy text markup for headers must match about-template.txt tou_text2 = ''.join( [(line if not line.isupper() else f'[H2]{titlecase(line)}[/H2]') + '\n' for line in tou_text.splitlines()] ) about_message = (about_file.read() % dict(VERSION = VERSION, PUBDATE = PUBDATE, PLATFORM = platname, COPYYEAR = PUBDATE.split()[1], TERMSOFUSE = tou_text2)) # headerized later on fill self.stage_about_message = about_message # ------------ # TERMS OF USE # ------------ # popup terms-of-use blurb on first run (also added to About tab above); # on all platforms; onerous, but makers need to protect themselves too; # popup ~last so appears on top (and above the calendars-folder popup); # use nextop to run calendars asks/load after this screen is dismissed; if self.runnum > 1: after_terms_of_use() else: self.info_message(f'[i]Welcome to {APPNAME}[/i]' '\n\n' 'Before you get started, please take a moment to ' 'read and agree to the following.' '\n\n\n\n' f'{tou_text2}' '\n\n\n' 'For reference, you can find a copy of all these ' 'statements in About of the app\'s main menu.', usetoast=False, nextop=after_terms_of_use) # now it's the user's turn traces('end app.on_start', '~' * 80) #==================================================================================== # App: utility methods #==================================================================================== def get_android_activity(self, docast=True): """ ------------------------------------------------------------------- Common preamble for pyjnius java-api code: the main activity (screen) ------------------------------------------------------------------- """ pythonact = autoclass(ACTIVITY_CLASS_NAME) mActivity = pythonact.mActivity # cast may be unwanted: see storage.py's ask_calendars_folder if docast: # normal activity = cast('android.app.Activity', mActivity) return activity else: # rare return mActivity def get_android_context(self, activity=None): """ ------------------------------------------------------------------- Common preamble for pyjnius java-api code: the app's context (info) ------------------------------------------------------------------- """ if activity == None: activity = self.get_android_activity() context = cast('android.content.Context', activity.getApplicationContext()) return context def get_android_version(self): """ ------------------------------------------------------------------- Get version of Android on device running the app. Cached for speed, though likely relatively trivial. This is android.os.Build.VERSION.RELEASE in Java; SDK_INT may be botched if rooted and nonstandard (?). Subtly, VERSION is a nested class, not a Build field: developer.android.com/reference/android/os/Build developer.android.com/reference/android/os/Build.VERSION NB: Python's sys.getandroidapilevel() is the API at _build_ time, not _run_ time, and won't suffice for checking the host device's version. E.g., Termux is always 24 (Android 7) today, on Android 10 and 13 phones. This seems useless for most apps' use cases, but host #s require pyjnius code below. UPDATE: Python now has platform.android_ver() which returns both the host's release (e.g., 13) and the build's API (e.g., 24, redundantly), but it requires Python 3.13+. ------------------------------------------------------------------- """ if hasattr(self, 'cache_android_version'): return self.cache_android_version else: VERSION = autoclass('android.os.Build$VERSION') apiversion = VERSION.SDK_INT # api/sdk version number, e.g., 35 andversion = VERSION.RELEASE # android version number, e.g., 15 trace('Host Android:', apiversion, andversion) # str may be x, x.y, x.y.z, or ? - drop all but x or x.y while andversion.count('.') > 1: andversion = andversion[:andversion.rfind('.')] numandversion = float(andversion) self.cache_android_version = numandversion return numandversion def dismiss_popup(self, popupcontent): """ ------------------------------------------------------------------- Close the topmost popup on the popups stack. Used for all modal popups; this coding avoids setting popup content's close handler after the popup is made (popup needs content, but content close needs popup...). popupcontent often passed from .kv, where it's dialog root. Now just a convenience interface to the popups stack. Kivy Popups also have an auto_dismiss, which runs dismiss() on taps outside the dialog and is enabled by default. UPDATE: pass Popup's content to avoid closing an overlaid dialog on double taps (see popups stack for more info). Also set Kivy auto_dismiss=False to prevent taps and closes outside scope of popups stack (they're also ambiguous+twitchy). ------------------------------------------------------------------- """ self.popupStack.dismiss(popupcontent) # topmost: no longer assumes only 1 modal @mainthread def info_message(self, pmsg, usetoast=False, longtime=False, nextop=None): """ -------------------------------------------------------------------- Show an info message as an Android Toast, or in-GUI overlay Popup. pmsg is the text to show+scroll in the popup: a Python string in toast. If usetoast, uses an Android Toast display on Android: 2-lines max. longtime is passed on to show_toast, for its popup duration. nextop is any zero-argument callable run after info-popup close. @mainthread is required to run graphics updates in main/GUI thread only. @run_on_ui_thread is apparently just for Android things like Toast. It's unclear why we're not in the main/GUI thread here, but... (DETAILS: @mainthread simply calls Clock.schedule_once(..., 0) to ensure that calls to the function are run on the next UI event-loop iteration ("frame"). This only matters in code run by another thread that must update the GUI. @run_on_ui_thread acheives a similar effect by running calls to the wrapped function on the Java Python activity's UI thread.) Pass nextop to chain one next dialog after the info popup: any callable. Caution: this doesn't support >1 overlapping modal dialogs as is (was). LATER: yes it does - Popups are now stacked for overlaps, and also allow for double taps without silently closing covered popups (see PopupStack). Double taps seems a scourge in Kivy, but may stem from touch scaffolding? Also now uses a shorter popup if usetoast and not on Android - no reason to take up most of the window for 1 cryptic line. This might also use macOS slide-down popups, but Kivy doesn't support them? (KivyMD?; tbd). UPDATE: the text is now scrollable in .kv, so go a bit smaller everywhere. LATER: delay the setting of the popup's text content until the next "frame," to avoid glitchy lag on slower phones. Else, the text displays as a scrunched single column initially, before being drawn in full. This was glaring on only one older/slower test phone (2018 Note 9), but may be an issue on lower-end phones in general. The delayed set has no noticeable impact on faster phones, and is the same solution used for larger texts in the Help and About tabs: see on_start() and on_tab_switch(). LATER: scale the popup per message size. Especially for narrow phones, 90% wide (link confirmation+chooser) where warranted will help. [4.0] Use "markup: True" in all screens and popups (including this) to allow Kivy text markup - code [] for bold, italic, underscore, colors, etc. But not if usetoast, per next method. -------------------------------------------------------------------- """ trace('info_message', nextop) if usetoast and RunningOnAndroid and len(pmsg.splitlines()) <= 2: # the arguably silly Android popup self.show_toast(pmsg, longtime) else: # toast is limited: make me an in-GUI modal popup if not nextop: canceller = self.dismiss_popup # close Info: modal, basically else: canceller = (lambda popupcontent: (self.dismiss_popup(popupcontent), nextop()) ) # shorter for one-liners # and wider for phones if 'big' (most are tall or wide) # sizer = (0.8, 0.5) if usetoast else (0.8, 0.8) tallmsg = pmsg.count('\n') > 3 widemsg = any(len(line) > 30 for line in pmsg.splitlines()) swidth = 0.9 if widemsg else 0.8 sheight = 0.8 if tallmsg else 0.5 sizer = (swidth, sheight) content = InfoDialog(oncancel=canceller) # delay message=pmsg popup = Popup( title='Info', content=content, auto_dismiss=False, # ignore taps outside size_hint=sizer, # sized per message, (x, y) **self.themeForPopup()) # fg/bg colors if 'light' self.popupStack.push(popup) self.popupStack.open() # show top Popup with Dialog def set_info_text(dt): hmsg = self.headerize_text_markup(pmsg) # replace any [H]s now content.message = hmsg # closure: enclosing scope Clock.schedule_once(set_info_text) # delay to avoid lag @run_on_ui_thread def show_toast(self, pmsg, longtime=False): """ ------------------------------------------------------------------- Show an info message as an Android Toast. Highly limited popup for short messages only. These can overlap poorly if longtime=True. Call this directly, or info_message(usetoast=True). See also: from kivymd.toast import toast [4.0] Kivy text markup will be displayed literally in Android toast - don't use [] markup in shorter messages. ------------------------------------------------------------------- """ # 2-line max (and should be narrow) pmsg = pmsg.strip().split('\n') pmsg = '\n'.join([pmsg[0], pmsg[-1] if len(pmsg) > 1 else '']) # Android API bit context = self.get_android_context() String = autoclass('java.lang.String') jmsg = cast('java.lang.CharSequence', String(pmsg)) Toast = autoclass('android.widget.Toast') duration = Toast.LENGTH_LONG if longtime else Toast.LENGTH_SHORT Toast.makeText(context, jmsg, duration).show() def postBusyPopup(self, kindtext='loading'): """ ------------------------------------------------------------------- Post a blocking modal dialog while a calendar load or save is being run in a thread. kindtext is 'loading' or 'saving'. Popup changed but not auto-closed by the app on thread completion, to both avoid a flash for fast actions and provide exit feedback: - [This] Starts with busy note, image animated, Okay disabled - [Next] After op, caller changes note, stops animation, enables Okay calendarsfolder has been ensured by ask before this is called. If alendarsfolder is empty, a user alert posts above this dialog. ------------------------------------------------------------------- """ message = ( '[b]Please Wait...[/b]' '\n\n' f'Frigcal is busy {kindtext} your ICS calendar files in:' '\n\n' f'....{self.storage.pprintFolder(self.settings.calendarsfolder)}' '\n\n' 'This popup will update when this operation finishes.') blocker = BusyDialog( message=message, onokay=self.dismiss_popup) # when enabled after thread popup = Popup( title='Busy Wait', content=blocker, auto_dismiss=False, # ignore taps outside size_hint=(0.9, 0.8), # phones wide, e-2-e short **self.themeForPopup()) # fg/bg colors if 'light' self.popupStack.push(popup) self.popupStack.open() # show top Popup with Dialog self._kindtext = kindtext return blocker def finalizeBusyPopup(self, blocker, animtest=False): """ ------------------------------------------------------------------- Update the busy-wait modal popup to reflect operation exit, per the preceding method's docs. calendarsfolder has been ensured (verified or set) by ask before this is called. ------------------------------------------------------------------- """ # change message blocker.message = ( f'Frigcal has finished {self._kindtext} your ICS files in:' '\n\n' f'....{self.storage.pprintFolder(self.settings.calendarsfolder)}' '\n\n' 'Please press Okay to continue.') blocker.ids.busyokay.disabled = False # enable Okay for user close if not animtest: blocker.ids.busyimage.anim_delay = -1 # stop animated gif blocker.ids.busyimage.reload() # reset gif: clear flips def themeForPopup(self): """ ------------------------------------------------------------------- Manually color a popup dialog about to be posted per the current theme settings in the app. KivyMD auto-theming requires all-KivyMD widgets. Theme colors work for most widgets from KivyMD like MDDialog, but not for core Kivy widgets like Popup and FloatLayout - popups' MDLabels had black text on dark grey in light mode. Hence, force colors. Recoding to use KivyMD only is work and may not be as nice for larger messages. Popup dialogs are all modal: theme won't change while they're open. MDLabels embedded in popup for text message auto-themed by KivyMD. ------------------------------------------------------------------- """ if self.theme_cls.theme_style == 'Dark': # per app's theme return dict() # do the Kivy default elif self.theme_cls.theme_style == 'Light': return dict(title_color='black', # match MDLabel text background='', # remove default base background_color=[1.0, 1.0, 1.0, 1]) # 'white', not transparent #background_color=[0.9, 0.9, 0.9, 1]) # off 'white', not transparent def open_web_page(self, url): """ ------------------------------------------------------------------- The Android Intent is automated by android package: https://github.com/kivy/python-for-android/blob/develop/ pythonforandroid/recipes/android/src/android/_android.pyx What webbrowser invokes on Android: Intent = autoclass('android.content.Intent') Uri = autoclass('android.net.Uri') intent = Intent() intent.setAction(Intent.ACTION_VIEW) intent.setData(Uri.parse(url)) activity = self.get_android_activity() activity.startActivity(browserIntent) ------------------------------------------------------------------- """ webbrowser.open(url) # yes, that's it (for Android and PCs) def launch_file_explorer(self, folder=None): r""" ------------------------------------------------------------------- Used for Calendar Files button in Help screen. folder is a URI on Android and a pathname on PCs. On Android: requires a file-explorer app; can this be lifted? On Linux: assume xdg-open installed, and background it via & else it blocks and may hang GUI on Windows WSL2 Linux (only). ABOUT WINDOWS/WSL2 PATH CONVERSIONS FOR EXPLORERS: This performs Windows <=> WSL2 pathname conversions because Windows may be using a pathname chosen on WSL2 and vice-versa: - On Windows: /home/me/temp => \\wsl.localhost\Ubuntu\home\me\temp - On WSL2: C:\users\me\temp => /mnt/c/users/me/temp Else, explorers crash with an exc on Windows and fail on WSL2. This differs from direct file opens. Windows can open calendars with a WSL2 folder path, but WSL2 cannot open calendars with a Windows path, and we do not do these conversions for calendar access. Hence, this buys us WSL2 path explores on Windows and Windows path explores on WSL2, but WSL2 is crippled for Windows paths (and why Windows can use a WSL2 path at all remains a mystery). But this is really only an issue for the source-code package, where a settings file may be shared by Windows and WSL2. In more-common executable installs, Windows and Linux will have separate settings files with platform-specific pathnames, and shared source-code folders seem highly unlikely to impossible. See also PC opens in run_with_calendars_folder() of storage.py. ABOUT ANDROID SAF PICKER CODE: See ahead; as coded, the picker may open on a Recents, weakly. ------------------------------------------------------------------- """ folder = folder or self.settings.calendarsfolder # path or URI if not folder: return # too soon? # windows <=> wsl pathname conversion (tests are partial) if (RunningOnWindows and (folder.startswith('/') and '\\' not in folder)): folder = \ os.popen(f'wsl wslpath -w "{folder}"').read().rstrip() or folder elif (RunningOnWSL2 and (re.match(r'^[a-zA-Z]:', folder) or '\\' in folder)): folder = \ os.popen(fr'wslpath "{folder}"').read().rstrip() or folder # pcs are easy if not RunningOnAndroid: # pcs are easy... try: if RunningOnWindows: os.startfile(folder) elif RunningOnMacOS: os.system('open "%s"' % folder) # allow spaces in pathname elif RunningOnLinux: os.system('xdg-open "%s" &' % folder) # ditto, xdg assumed on host except: # e.g., os.startfile can't access folder for wsl2 path trace(folder, sys.exc_info(), sep='...\n') self.info_message(f'Cannot explore {folder}', usetoast=True) else: # android is not... # use SAF/AOSP/builtin/DocumentsUI explorer because could not get # the explorer-app code from PPUS below to work with content URIs; # this code abuses the SAF picker (choice is ignored) but suffices # for a simple files list and does not require an explorer app; # regrettably, this UI lists only Recent files (either due to an # undocumented coding error or in an attempt to be "user friendly"), # but Android Intents in this role are about as wonky as it gets, # and this app action is not useful enough to agonize over more; Intent = autoclass('android.content.Intent') DocumentsContract = autoclass('android.provider.DocumentsContract') folderUri = folder # str, not Uri.parse(folder) here intent = Intent(Intent.ACTION_GET_CONTENT) # _VIEW, _GET_CONTENT, _OPEN_DOCUMENT? intent.addCategory(Intent.CATEGORY_OPENABLE) # a real file, essentially intent.setType('text/calendar') intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, folderUri) # andr 8+ only activity = self.get_android_activity() activity.startActivity(intent) # and a lame Recents UI opens # this failed to add a title to DocumentsUI # String = autoclass('java.lang.String') # intent = Intent.createChooser(intent, String(folderUri)) # these failed in later androids: no-ops # intent.putExtra("android.content.extra.SHOW_ADVANCED", True) # intent.putExtra("android.content.extra.FANCY", True) # intent.putExtra("android.content.extra.SHOW_FILESIZE", True) # this fails too: DoumentsUI is not for browsing as of Android 12? # ComponentName = autoclass('android.content.ComponentName') # intent = Intent(Intent.ACTION_VIEW) # intent.setComponent(ComponentName('com.android.documentsui', 'com.android.documentsui.files.FilesActivity')) """FAIL # alternative tried to open the content URI in a file-explorer # app (like PPUS does with a pathname via All Files Access), # but failed for reasons TBD and not worth chasing further; # opens file explorer apps, but all report errors for the URI; # downside: unlike SAF, it requires an explorer-app install; activity = self.get_android_activity() #folder = self.storage.pprintFolder(folder) # no: drop any %xx URI escapes? Uri = autoclass('android.net.Uri') uri = Uri.parse(folder) # required for content URI Intent = autoclass('android.content.Intent') intent = Intent(Intent.ACTION_VIEW) intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) # need on android 7+ intent.setDataAndType(uri, 'resource/folder') # check if a handler app exits, else fails with exc if not intent.resolveActivity(activity.getPackageManager()): self.info_message('Please install a file-explorer app', usetoast=True) else: try: activity.startActivity(intent) except Exception as E: # unclear if any errors ever come here trace(folder, uri, sys.exc_info(), sep='...\n') self.info_message(f'Cannot explore {folder}', usetoast=True) FAIL""" def get_and_inc_run_counter(self): r""" -------------------------------------------------------------------- Fetch + update the run-counter file. Used on all platforms to show terms of use on first run. POSIX/filepaths are OK here on Android because accessing app's own private folder. The write() here can easily fail if users unzip the Windows package in C:\Program Files. Catch this at call and issue a popup with tips, and close. User should unzip anywhere else; permission mods (once) and run-as-admin (always) are harder. -------------------------------------------------------------------- """ # get run counter on all plaforms apppriv = self.storage_path_app_private() or '' countpath = osjoin(apppriv, RUNCOUNT_FILE) if not osexists(countpath): countruns = 1 else: countfile = open(countpath, 'r') # in app-private, not install countruns = int(countfile.read()) + 1 countfile.close() # save new run counter on all platforms (POSIX + filepaths for all) countfile = open(countpath, 'w') countfile.write(str(countruns)) countfile.close() trace('#Runs:', countruns) return countruns # for terms of use, possibly others later def storage_path_app_private(self): """ -------------------------------------------------------------------- Borrowed from PPUS: app's own, private, storage folder. Used to store public and persistent reources, which in FC 4.0 means config-settings and run-counts files only. Nit: not in storage.py because not for _calendars_ storage. POSIX+pathnames OK for result path on all platforms: it's accessible to the app. User may access the result path on PCs but not on Android, which backs up these items to users' accounts if logged in/enabled. macOS maps this to ~/Library because translocation of unsigned apps may make the app's own install folder unusable for persistent data. Windows and Linux simply use the executable's install/unzip folder. Android details: The result here is /data app-private "internal" storage, which is unusable for most user content. It's fully app-private (sans file provider+SAF), auto-uploaded to Google account for reinstalls (sans user disable), and nuked on uninstall (sans hasFragileUserData toggle). This is not app-specific storage at /sdcard/Android/data nor general shared storage at /sdcard. App-specific is no longer accessible to file-explorer apps in 2025 (except Files==AOSP), and shared requires user permissions via All Files Access or the SAF chooser just for admin items. HENCE: there is no user access to these items on Android (though there is no obvious reason why user access would be required). See also storage.default_calendar_folder_path()'s similar dilemma. API: this is Context.getFilesDir() in Java => the files/ subfolder of app private. The app install is in its (hidden) app/ subfolder. It has at least 2 names on Samsung phones, /data/data + /data/user/0, and Python tools don't map the two to a common canonical form (yet?). There is also Context.getDataDir(); and Environment.getDataDirectory() for a home folder, which seems to just be the parent folder of Context.getFilesDir(), and shouldn't be used (supposedly...). -------------------------------------------------------------------- """ if RunningOnAndroid: # ask Android where the app-private folder is, per above # /data/data/com.quixotely.frigcal/files, a.k.a. /data/user/0/com... # not really 'public' on Android, but perstent and auto-backedup path = android.storage.app_storage_path() trace('app-priv:', path) return path elif RunningOnMacOS: # don't use install unzip on macos, becuase its security-based # translocation may disallow app's own folder: use ~/Library # see https://learning-python.com/pyedit.html#autosave2025 if not hasattr(sys, 'frozen'): return '.' # source, dev else: applib = osjoin(os.path.expanduser('~'), 'Library', APPNAME) if not osexists(applib): os.makedirs(applib) # make Library too if needed, vs mkdir return applib else: # Windows, Linux: cwd, which is app install/unzip folder # app requires write access to this: don't install in C:\Program Files # frozen exes use install unzip, not sys.MEIPASS run unzip: a temp dir if not hasattr(sys, 'frozen'): return '.' # source, dev else: exepath = sys.executable exedir = os.path.dirname(os.path.abspath(exepath)) # install/unzip folder return exedir # use '.' for all extras def set_font_size(self, fontsize): """ -------------------------------------------------------------------- On defocus for font size in Settings: setting the App instance's dynamic_font_size here automatically changes font size for EVERY wdget in GUI, because Widget font_size is bound to it in the .kv file. Powerful (and arguably scary). Alternatively, GUIs can bind font_size to this property in selected widgets. This size is in absolute pixels as coded, but could append size string with 'dp' or 'sp' to use density and user+density scaling: see kivy.org/doc/stable/api-kivy.metrics.html#kivy.metrics.sp. This seems arbitrary (a relative # is a relative #), but the preset defalt may need to be scaled per device on startup. Kivy's default is '15sp' which maps to '39' pixels on a Note20. LATER: use 'sp' pixels to avoid the scaling issues of preset... Downside: this provides less granularity then absolute pixels (1 sp pixel is about 2.56absolute pixels on screens like the Note20's), and could probably specialize preset by platform. LATER: use the fontsize from the GUI as a string and allow input per float filter, instead of assuming an int(). This allows fraction sp pixels to compensate for the granularity. LATER: for better granularity, use absolute int pixels in GUI, but set default/start to '15sp' equivalent per kivy.metrics (39 on Note20). This scales per device and user settings on first run, though it won't auto-update for user settings till rerun. LATER: this is now also run on startup and restore defaults to set fontsize; else user must click Apply each run/restore. NB: don't need to mod self.width to force widgets' minimum_width to recalc for scrolling - happens automatically when font changed; NB: see common.kv for use of dynamic_font_size and font_size. KivyMD's overrides require it to be reset after some creates. -------------------------------------------------------------------- """ # there be much magic here... if fontsize != 0: # zero=keep default self.dynamic_font_size = fontsize # abs, not + 'sp' # force Help/About refills that rescale headers for new fontsize # app is in startup or on Settings screen: no need to refill now self.help_text_set = False self.about_text_set = False '''CUT def set_font_name(self, fontname): """ -------------------------------------------------------------------- Like font_size but for font_name, added in FC 4.0, marginally useful. UNUSED: Per common.kv, setting font_name globally in Widget would break KivyMD menu icons and hamburger button, so it's not done. Unlike font size, this makes font name apply only to new text, not text already displayed, and font name in general applies only to a subset of text widgets, not all. PUNT on font-name setting. -------------------------------------------------------------------- """ if fontname != '': # empty=keep default self.dynamic_font_name = fontname # one of kivy's builtins CUT''' def headerize_text_markup(self, text): """ -------------------------------------------------------------------- Dynamically scale the fontsize of headers in marked-up text per the user-changeable global fontsize setting. Done for Help and About screen text, info_massage() notes, and other popup dialogs. There's no way to do this with Kivy markup sans text replacement. Must be redone for Help and About on fontsize mods in Settings: already-loaded flags are removed to force refills in screens when next viewed, and [H] are retained in text to defer replacements. This is open-ended styling, but use Kivy []s where they suffice. Underlines iffy because they look like links, but OK in headers. Size here is unscaled pixels because dynamic_font_size is too: use a proportional add-on instead of screen-dependent constants. The multipliers mean: 1.25 = +1/4, 1.125 = +1/8, 1.0625 = +1/16. For abs @: @32 => 40, 36, 34; @25 => 31, 28, 26; @40 => 50, 45, 42. Example: [H1]Gestures[/H1] => [b][color=#2299ff][size=32]Gestures[/size][/color][/b] -------------------------------------------------------------------- """ hdrcolor = '#2299ff' # works in dark and light themes replaces = { '[H1]': f'[u][b][color={hdrcolor}][size={int(self.dynamic_font_size * 1.25)}]', '[/H1]': '[/size][/color][/b][/u]', '[H2]': f'[b][color={hdrcolor}][size={int(self.dynamic_font_size * 1.125)}]', '[/H2]': '[/size][/color][/b]', '[H3]': f'[color={hdrcolor}][size={int(self.dynamic_font_size + 1.0625)}]', '[/H3]': '[/size][/color]', } for tag in replaces: text = text.replace(tag, replaces[tag]) return text def set_system_bars_fg_color(self): """ -------------------------------------------------------------------- On all Androids, set the foreground color of system's status and 3-button navigation bars to jive with the app color theme. Called on menu Theme toggles (see on_theme) and initially when either setting up edge-to-edge display with transparent bars or forcing bar bg colors on older androids (see on_start, which uses the same sort of wacky Java-api code). This is independent of and ignores device theme on purpose: app has own Theme (Kivy has no direct device-theme access). The navbar color also applies only to the 3-button navbar; the gestures navbar is always transparent with a neutral grey fg bar (and also works well with insets padding). WindowInsetsController not found on Android 8 and 9 but is on 10+ (it was added in 11, and androidx is used): punt. -------------------------------------------------------------------- """ if (not RunningOnAndroid) or (self.get_android_version() <= 9): return trace('setting bars fg') activity = self.get_android_activity() AndrRId = autoclass('android.R$id') window = activity.getWindow() rootview = window.getDecorView().findViewById(AndrRId.content) @run_on_ui_thread # @mainthread fails def setter(): WindowCompat = autoclass('androidx.core.view.WindowCompat') insetsController = WindowCompat.getInsetsController(window, rootview) if not insetsController: trace('set_bar_fg: no controller') else: applight = self.theme_cls.theme_style == 'Light' insetsController.setAppearanceLightNavigationBars(applight) insetsController.setAppearanceLightStatusBars(applight) # avoid android 10- java exception: # "Only the original thread that created a view hierarchy can touch its views" setter() def set_system_bars_bg_color(self, initial=False): """ -------------------------------------------------------------------- On all Androids, set the status and 3-button navigation bars' background color per app theme and support of host Android. Bg always starts as black in styles.xml, to jive with the always-black Kivy splashscreen. Else, during splashscreen, Androids 14- show a dark-on-grey nav bar and a white-on-white status bar (with a dark artifact on some). After the splashscreen is closed, override the styles.xml initial black in on_start(), and invert it if needed for for main-menu Theme taps in on_theme(), by these rules: - On Androids 15+, set only nav bar bg to transparent. Status bar is always transparent by Android policy. Edge-to-edge is both enabled by the app and enforced by Android, so the KivyMD app-theme bg color shows under bars via edge-to-edge padding. - On Androids 11..14, set both status and nav bar bgs to transparent. Edge-to-edge is enabled by app, so the KivyMD app-theme bg color shows under the bars as padding. - On Android 10, set both bars to the color of the KivyMD app-theme bg color. Edge-to-edge is not enabled by the app due to missing API call getInsets() (despite using androidx), so the app-theme bg won't appear under transparent bars. - On Androids 8..9, bars don't respect app theme and e-2-e is not usable. WindowInsetsController, required to set bar fg per app theme, is unavailable on 9- (despite using androidx), so system bars are always black in dark and light app themes. - Android 7- are unsupported because some phones lack the timestamps support required for calendar-file backups. All of which reflects as-vetted parameters and is about as convoluted as it can be, but such is life in Android dev. rgb()s below are KivyMD 'Dark'/'Light' app-theme bg colors. In Android 15+, the status bar bg is Color.TRANSPARENT by policy and ignores both color calls and XML settings, but the nav bar default is auto-color + 80% translucent/opaque. This code, like the styles.xml colors code it overrides, uses deprecated tools, but there are no known alternatives today. Apps are now probably expected to accept policy defaults for bar colors based on device theme, but this doesn't work with in-app themes like those in KivyMD. Nit: the @run_on_ui_thread defers this call, which makes bars dark grey on Android 10 (only) until initial popups closed, and there seems no way to prioritize this. Meh. -------------------------------------------------------------------- """ if (not RunningOnAndroid) or (self.get_android_version() <= 9): return trace('setting bars bg') Color = autoclass('android.graphics.Color') transparent = Color.TRANSPARENT # also #00000000, @android:color/transparent solidblack = Color.rgb(18, 18, 18) # app-theme backgrounds solidwhite = Color.rgb(250, 250, 250) @run_on_ui_thread # @mainthread fails def setter(): activity = self.get_android_activity() window = activity.getWindow() andrvers = self.get_android_version() try: if andrvers >= 15: window.setNavigationBarColor(transparent) elif andrvers >= 11: window.setStatusBarColor(transparent) window.setNavigationBarColor(transparent) elif andrvers == 10: appthemebg = ( solidblack if self.theme_cls.theme_style == 'Dark' else solidwhite) window.setStatusBarColor(appthemebg) window.setNavigationBarColor(appthemebg) except: trace('except in set bars bg color') # in case exc if calls removed trace(sys.exc_info()) # avoid android 10- java exception?: # "Only the original thread that created a view hierarchy can touch its views" setter() #==================================================================================== # App: GUI startup tweaks #==================================================================================== def startup_gui_and_config_tweaks(self): """ Assorted mods to gui and configs for platform diffs/etc, post load of persisted setings. Called explicitly from App.on_start(), only after load_settings has initialized or loaded settings. FC: this is now mostly unused code from PPUS. Some that does not require an App 'self' was moved to prestart_pc_tweaks.py. """ # --------- # FONT SIZE # --------- # uses globalfontsize, already loaded by the settings system; # apply settings's font size now, else user must tap it each # run, even though settings has been set to the last size saved; # see also restore-defaults, which similarly auto-applies value; # update: this, and every other context, now does same for name; # update: font-name setting was dropped - see rationale elsewhere; self.set_font_size(self.settings.globalfontsize) #self.set_font_name(self.settings.globalfontname) # CUT # ------- # SCROLLS # ------- if not RunningOnAndroid: # all PCs # default scrolling is too slow on PCs (only) [TBD - gui] # TBD: +allpopups +others? scrollviews = ( self.root.ids.navscreenmgr.get_screen('help').ids.helptextscroll, self.root.ids.navscreenmgr.get_screen('about').ids.abouttextscroll) for scroll in scrollviews: if RunningOnWindows or RunningOnLinux: scroll.scroll_wheel_distance = 20 scroll.smooth_scroll_end = 20 else: scroll.scroll_wheel_distance = 10 # macos is special scroll.smooth_scroll_end = 60 # ------- # PC DOTS # ------- # moved to the new prestart_pc_tweaks.py # ----------- # WINDOW SIZE # ----------- """UNUSED if (RunningOnWindows or RunningOnLinux): # not Android or macOS # FC - UNUSED: disable this temp workaround because Windows and Linux # defaults now work well. This code was required for PPUS's Kivy 2.1.0. # With newer Kivy 2.3.1 used in FC, this code is no longer needed on # Linux, and on Windows it makes the window so large that it's partly # off screen even with the position code below: drop it. initial_center = Window.center # save for position recenter next lx = 150 if RunningOnLinux else 0 # was 100 Window.size = (kivy.metrics.dp(748+lx), kivy.metrics.dp(612+lx)) # (w, h) #Window.size = (748, 612) # different: too small on low-res dell #Window.size = (1120, 920) # original: too small on high-res yoga UNUSED""" # --------------- # WINDOW POSITION # --------------- """UNUSED if (RunningOnWindows): # not Linux, macOS, or Android # FC - UNUSED: disable this temp workaround because Windows default # now works well. This code was required for PPUS's Kivy 2.1.0. # With newer Kivy 2.3.1 used in FC, the window appears partly off # screen because it's too big... which means the preceding size code # breaks it. With the size code disabled it now has no effect: punt. # recenter instead of pinning to abs location new_center = Window.center # center after resize above diffx = new_center[0] - initial_center[0] # how much did center change? diffy = new_center[1] - initial_center[1] Window.left = max(5, Window.left - diffx) # adjust top-left for change Window.top = max(25, Window.top - diffy) # but not past top-left corner trace('Repos:', initial_center, new_center) # @2k: (400.0, 300.0) (561.0, 459.0) UNUSED""" # ----------- # TEXT ALIGNS # ----------- """UNUSED # FC - UNUSED: FC uses KivyMD labels and text, not Kivy TextInput. # Force left-align text in Main tab's path fields (else pseudo-random) # weirdly hard, but halign and scroll_x in .kv had no effect, for reasons tbd # scheduling separately helps, but still fails on 1 of 6 test devs (andr9) # UPDATE: this is related to font size: moving below that setting here # fixed andr9 too; clock scheduling is still required, but just once... def leftalign(field): self.ids[field].cursor = (0, 0) Clock.schedule_once(lambda dt: (leftalign('frompath'), leftalign('topath'))) #Clock.schedule_once(lambda dt: leftalign('frompath')) #Clock.schedule_once(lambda dt: leftalign('topath')) UNUSED""" #==================================================================================== # App: app closes #==================================================================================== def shutdown_app(self, popupcontent=None): """ -------------------------------------------------------------------- The quest for a hang-free app close on Android: forcibly close app to avoid state-related hangs. Not ideal, but other options were prone to hang. Now used in all app-exit contexts: Back, close, fatal errors. NOT run for Recents upswipes on Andoid (saves here won't help). popupcontent is optional and ignored: may be called from a popup or not, but this is shutdown, right? History: kivy closes were seen to hang sporadically in initial PPUS Android app dev, with no useful info in the log ("Leaving application in progress..." was displayed, but the GUI didn't close). As a workaround, force the close with either self.stop(), sys.exit(), or os._exit(). These may dork up kivy state (and sys.exit may be caught/disabled by kivy), but the app run is over anyhow. The atexit module's handlers are not run for os._exit - a last resort. -------------------------------------------------------------------- """ traces('os._exit: bye', '-' * 80) # TBD: auto-save calendars if settings? os._exit(0) # nuclear option: this feels wrong, but it works... def warn_before_exit(self, changedCalendars): """ -------------------------------------------------------------------- Confirm app close request with user. Used in three contexts: - On all platforms, suggest a Save when user tries to close app with Back or Escape and there ARE unsaved calendar changes. - On PCs, suggest a Save when user tries to close app with a window "X" or other and there ARE usaved calendar changes. - On Android, verify intent when user taps Back to close and there are NOT unsaved calendar changes: Back is easy to tap inadvertenly. - On PCs, close silently if there are NOT unsaved calendar changes. This explcit model replaces a two-step save?+close? dialog model in FCs 3.0-. Never run on Recents-swipe closes, sadly (see ahead). -------------------------------------------------------------------- """ message_changed = ( '[b]Unsaved calendar changes[/b]' '\n\n' '[u][i]Caution[/i][/u]: ' 'some of your calendars have unsaved changes.' '\n\n' 'To save these changes, tap Cancel and then Save Calendars ' 'in the main menu. ' 'To discard changes and close the app now, tap Confirm.') message_unchanged = ( '[b]No unsaved calendar changes[/b]' '\n\n' 'None of your calendars have unsaved changes and no Save ' 'is required. Tap Confirm to close the app now or Cancel to ' 'keep using it.') message = message_changed if changedCalendars else message_unchanged confirmer = ConfirmDialog( message=message, onyes=self.shutdown_app, onno=self.dismiss_popup) popup = Popup( title='Confirm App Close', content=confirmer, auto_dismiss=False, # ignore taps outside size_hint=(0.9, 0.8), # phones wide, e-2-e short **self.themeForPopup()) # fg/bg colors if 'light' self.popupStack.push(popup) self.popupStack.open() # show top Popup with Dialog def catch_back_button(self, window, key, scancode, codepoint, modifiers): """ -------------------------------------------------------------------- On keypress 27 -- which is Escape on keyoards and also means Back button tap or gesture on Android -- apply one of these in turn: - Go up one level if PC-only folderchooser open - Ignore if any modal dialogs open - Return to the Month screen if another screen is open - Warn and confirm if any unsaved calendar mods - Confirm on Android always: too easy to tap inadvertently - Else close app: we don't stack screen history, so Back sans chooser, dialogs, subscreen, or mods can only mean exit. NOTE: the mapping from Back to the filechooser's .back() is moot on Android because that platform uses the SAF picker, not the KivyMD chooser (and Back is caugth+handled by SAF). NOTE: some web examples check for key=1001 too, but this was presumably for an older version of Kivy. 27=Escape suffices today for both Android Back button and keyboard Escape key. TBD: _should_ a Back tap sans calendar changes close the app, or should it simply enter pause state? There seems no real norm for this, but a full close requires calendar reloads. There is this for Java Activity, but it's supposed to be the default on Android 12+ so Kivy may prevent pause on Back: public void onBackPressed() // Move task containing this activity to back of stack moveTaskToBack(true); FUTURISMS: Back becomes _predictive_ when apps target and run on Android 16+: requires a callback model, and the API Kivy uses to get here no longer works (per docs, "onBackPressed is not called and KeyEvent.KEYCODE_BACK is not dispatched anymore"). Apps can opt out via build files, but the opt out goes away if target 17+. To postpone changes, FC 4.0 selects the opt-out option on this. The keycode may have changed in Kivy, but is still 27 in 2.3.1. Kivy also changed window centering on Windows; use auto scheme. UPDATE: on Android 16 without an opt-out for predictive-back, Back is never caught here, and Android always puts the app in pause state (it's moved to Recents after an on_pause()) but does not kill it, regardless of whether calendars have changed or not. This seems atypical, but is it as good as prompt+close? Sans Back, there's no indiction of unsaved calendar changes (TBD). -------------------------------------------------------------------- """ # return: True=end key processing, False=continue processing key if key != 27: # Escape key and Android Back return False # False=continue processing key else: trace('back button press') if self.folderchooser_open: # never true on Android # go up if chooser open self.folderchooser.back() # using KivyMD chooser return True # True=stop processng key elif not self.popupStack.empty(): # ignore if any modal dialog open return True elif self.root.ids.navscreenmgr.current != 'month': # change to Month screen if not already there - like hbmenu Month tap self.switch_to_screen('month') self.manage_menu_post_click(self.root.ids.month, toggle=False) return True elif any(CalendarsDirty.values()): # ask before close everywhere if mods self.warn_before_exit(changedCalendars=True) return True elif RunningOnAndroid: # ask before close on android if no mods self.warn_before_exit(changedCalendars=False) return True else: # close now - sans hanging! # tbd: or simply pause on Android? self.shutdown_app() # never returns def on_request_close(self, *args, **kargs): """ -------------------------------------------------------------------- Window exit: warn user and cancel if unsaved calendar mods, else close app. Not called for the Back button: intercepted by catch_back_button(). Called in various context on PCs and Android. On PCs, always called for window "X" and perhaps other app closes. On Android, may vary per version and vendor (as usual). Per recent tracing, this is NOT called on an upswipe on the app in Recents: we get on_pause() on Recents open but then nothing more, so there's no way to prevent loss of calendar changes :-(. Also on Android, not called for a close in splitscreen mode but IS called for a close in popup mode in a Samsung device (oddly). Since there is nothing the app can do to avoid the close in popup mode (returning True doesn't stop close and dialog can't be shown), this simply returns False, and calendar changes are again lost :-(. Caveat: older notes per PPUS which seem to contradict the above, but may hold true for apps with a running foreground service?... "Curiously, this is still invoked on Android for Recents-swipe closes, which trigger on_pause, on_resume, this, and on_stop when a service is running (and the first three of these with no service - sometimes), but cannot be disabled by a True return here in any event (so bail)." -------------------------------------------------------------------- """ # return: True=don't exit, False=do exit trace('app request close') if RunningOnAndroid: # not run for Back, Recents swipe, split close return False # can't stop|dialog if run for popup close... elif any(CalendarsDirty.values()): # ask before close self.warn_before_exit(changedCalendars=True) return True else: # close now - sans hanging! self.shutdown_app() # never returns def on_pause(self): """ -------------------------------------------------------------------- Android only: on user leaving the running app by picking another in Recents or Android taskbar. Also run when SAF picker opened. Not triggered when up-swipe to kill app in Recents (probably: see note above). Returning True leaves app in paused state instead of killing it now and restarting it fully on resume (True is the default, but the docs seemed askew on latest read). ==== Scroll workaround: per a known Kivy defect, pausing an app during scroll animation sometimes makes scrolled Labels' text empty on resume. This affects Help and About screens only but is glaring. Their text may reappear immediately at other positions, reappear after a delay (up to ~20 seconds), or never reappear at all. App pause while scrolling is required to trigger this glitch, so it's rare, but these screens' web links and calendar-files explore raise the odds of app switches. Workarounds pursued: 1) FAIL: use Kivy's effect_cls.halt or .cancel here to end the animation, on the theory that queued motions botch the widget on resume. This was abandoned because neither seemed to have any effect, and using either prevents on_resume() from being called later for reasons tbd. Kivy's Animation.stop_all(scroll) was also explored, but this seems to be both ineffectual and unused by scroll effects per a brief code tour (docs are thin). 2) PASS: per Kivy docs, blank widgets may reflect the fact that OpenGL context might not be preserved across pause/resume, because Android may destroy it to free up resources. Android's API has a setPreserveEGLContextOnPause() to avoid destroying OpenGL contexts on pauses, but it's a no-op on some devices, is perilous to use, and should not be required. Per the web, using this may terminate or destabalize apps in some contexts, and Kivy already has mechanisms to restore lost OpenGL contexts on resume (that's why pause works at all). Kivy's mechanism smimply seems to be failing for in-progress scrolls. 3) PASS: disable pause mode by returning False here. This would require restarting the app in full on resumes, losing any user context and unsaved calendar changes in the process. 4) PASS: switch from multiple Labels to RSTDocument. Kivy docs warn that this is experimental and unstable, it seems too much for the simple Help/About screens, and it was unvetted for bugs. 5) USED: reset or reload the scroll's labels' text in on_resume below, on the theory that the OpenGL context might have been damaged or freed and requires an explicit reload or re-render of the scrolled text content to force creation of new textures. Passing reuse as True or False to the splitter seems the same, though False yields a minor twitch (see set_split_label_text()). 6) USED: reduce the size of the Labels used for scrolled text, on the theory that this may simplify OpenGL context restores. They were formally halves (2 Labels per screen) to avoid blank text on older devices with small texture limits, but this was generalized to support larger text and more and smaller labels. Using a minimal 1 line per label did not differ from 20 or 50. RESULTS: a combination of #5 and #6 did _not_ fix blank text on scrolls, and no other workarounds are known (and RecycleView is right out). While blank text still occurs occasionally, the generalization for #6 is now usable in other apps (e.g., PPUS). But like other Kivy glitches (see PPUS's vanishing tabs bar, empty splitscreen text, and rightways rotation navbar overlays), this one is rare but unfixable: PUNT. All of which seems a lot to ask of app devs just for displaying text in a GUI, but Toga--the only alternative to Kivy for Python on Android--today lacks both gestures and nav drawers. Improve me. -------------------------------------------------------------------- """ trace('App.on_pause') # stop scroll animation iff it's the current screen # no: this prevents on_resume() from being called for Help+About """PUNT if self.root.ids.navscreenmgr.current == 'help': trace('pause help scroll') screen = self.root.ids.navscreenmgr.get_screen('help') scroll = screen.ids.helptextscroll scroll.effect_cls.cancel() #scroll.effect_cls.halt() #Animation.stop_all(scroll) elif self.root.ids.navscreenmgr.current == 'about': trace('pause about scroll') screen = self.root.ids.navscreenmgr.get_screen('about') scroll = screen.ids.abouttextscroll scroll.effect_cls.cancel() #scroll.effect_cls.halt() #Animation.stop_all(scroll) PUNT""" return True # True=pause, don't stop now, run on _resume() later def on_resume(self): """ -------------------------------------------------------------------- Android only: on user returning to the paused app by picking it in Recents or Android taskbar. Also run when SAF picker closed, and when getting focus by initial tap in split-screen mode (kivy bug?). Not triggered if repick app in either Recents or Apps after killing it with a Back button or a Recents upswipe: on_start() comes after both, with unpacking logcat msgs for a fresh run. Return is moot. See on_pause() above for text-reset rationale. TBD: as coded, skips reset unless scrolled text is on the current screen; if it's required to reset always, use [if hasattr(self, 'help_text_set')]. The lambdas are superfluous+harmless if reuse=True, its default. NB: halting the animation in on_pause() prevents on_resume() calls. -------------------------------------------------------------------- """ trace('App.on_resume') # placeholder for future expansion # reset scrolled text iff it's the current screen currentscreen = self.root.ids.navscreenmgr.current trace(f'{currentscreen=}') if currentscreen == 'help': trace('reset help text') Clock.schedule_once( # reset label text lambda dt: self.set_help_text(dt, reuse=True)) # just s_h_t if r=True elif currentscreen == 'about': # else avoid overhead trace('reset about text') Clock.schedule_once( lambda dt: self.set_about_text(dt, reuse=True)) def on_stop(self): """ -------------------------------------------------------------------- Android only: NOT called on Recents upswipes and NOT called if the app is paused (post on_pause()) and the OS kills the app for memory constraints or any other. Hence, not very useful: save any saves in on_pause too or only. Exception: IS called for popup-mode close after an on_pause() and on_resume(), though spltscreen close only on_pause() and NOT this (well, on Samsung). Recents sipes call none, but on_pause() is run when the user selects the Recents screen. -------------------------------------------------------------------- """ trace('App.on_stop') # placeholder for future expansion #======================================================================================== # Run that app #==================================================================================== Frigcal().run() # build(), on_start(), and watch for user events