File: Frigcal/code/Frigcal--source/main.py

#!/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