name: gym-notes-context description: "Use when working in the Gym Notes Flutter app (gym_notes_track_app). Loads product purpose, architecture, persistence rules, l10n requirements, validation commands, and UX direction for this offline-first gym progress tracker built on Flutter, BLoC, Drift SQLite, table_calendar, and re_editor. USE FOR: implementing or changing folders, notes, markdown editor, markdown shortcuts, counters (global and per-note), calendar/events, backup/restore, multi-database management, settings, onboarding, search, or anything touching workout-tracking workflows. DO NOT USE FOR: unrelated Flutter projects or generic Dart questions."
Gym Notes Context Skill
Load this whenever a task touches the gym_notes_track_app Flutter workspace. It sets product framing, architecture rules, and validation steps so changes stay consistent with the existing app.
1. Load The Canonical Context
Read COPILOT_CONTEXT.md before planning or editing. It is the source of truth for:
- Product purpose and user workflows (gym/workout tracking, notes, counters).
- Non-negotiable rules (localization, generated files, build_runner, no unsolicited tests/comments).
- Stack, architecture flow, main feature areas, persistence rules.
- Localization, UI/UX direction, error handling, validation commands.
- Defaults for adding new gym progress features.
Do not restate that file back to the user; just follow it.
2. Quick Decision Checklist
Before writing code, confirm:
- Does the change require new persisted data?
- If yes, decide between: note markdown content, a counter, a setting, or a new Drift table/migration.
- New tables/columns require a Drift migration and a
dart run build_runner build --delete-conflicting-outputsrun. Never reset storage.
- Does the change add or rename user-visible text?
- If yes, update
lib/l10n/app_en.arb,app_de.arb, andapp_ro.arb, then runflutter gen-l10n.
- If yes, update
- Does the change touch BLoCs, services, repositories, or DAOs?
- Keep the flow
Page → BLoC → Service → Repository → DAO → Drift. - Match the local style for sealed/Equatable states, events, and
copyWith. - Invalidate caches after creates, updates, deletes, moves, and reorder ops.
- Keep the flow
- Does the change affect the editor, auto-save, or note position?
- Preserve auto-save reliability (debounce, interval, retry, lifecycle flush).
- Keep cursor and preview position persistence behavior intact.
- Does the change affect counters?
- Respect
scope(globalvsperNote),isPinned, ordering, andnoteId == ''for global values incounter_values. - Counter shortcuts use
CustomMarkdownShortcut.counters(List<CounterBinding>, max 2) with{c1}/{c2}tokens inbeforeText/afterText/ repeat wrapper text. Each binding has its ownCounterOp(increment/decrement). Tokens are expanded byShortcutAppliervia theCounterMutatorcallback, which routes throughCounterBlocand respects scope. Each token occurrence mutates once per repeat iteration. Keep the legacycounterIdfield populated wheninsertType == 'counter'so existing single-counter shortcuts continue to work; rely onshortcut.effectiveCountersto read bindings uniformly in new code.
- Respect
- Does the change affect backup/restore?
- Add new persisted fields to backup export/import without breaking older backups.
- Does the change touch import/export of notes or folders?
- Go through
ImportExportBlocfrom the UI; never callImportExportServiceorSharePlusdirectly from a page/widget. - Preserve
createdAt/updatedAtand folder sort preferences across round-trips by routing through theimportXmethods (FolderDao.importFolder,NoteDao.importNote, plus the matching repository/service wrappers). UsecreateXonly for genuine user-initiated creates. - Bumping the archive schema requires bumping
ImportExportService.archiveVersionand updating_assertSupportedManifestto accept the previous version. - Any export entry point must use
shareExport(auto-cleans the temp file) and rely on the existing startupsweepStaleExportscall inmain.dart.
- Go through
- Does the change touch the markdown preview?
- Keep the layering:
Page -> MarkdownPreviewBloc -> MarkdownRenderService -> LineBasedMarkdownBuilderandMarkdownPreviewBlocView -> SourceMappedMarkdownView. - Never put
InlineSpans or builders in bloc state; bumprenderHandleand let the widget pull spans frombloc.renderServiceon demand. - Theme dispatch (
PreviewThemeChanged) belongs inMarkdownPreviewBlocViewlifecycle hooks, never inbuild(). - Forward scroll progress directly to
bloc.scrollController.updateProgress(...); do not route per-frame scroll signals through the event queue. - Wire link taps via
MarkdownPreviewBlocView.onTapLink. The page-level handler must validate URL schemes (allowed:http,https,mailto,tel) before callinglaunchUrl, and surface failures viaCustomSnackbar.showErrorwith thelinkOpenFailed/linkSchemeNotAllowedARB keys. - Content sync pattern: call
bloc.bindContentProvider(() => controller.text)once ininitState. On every keystroke callbloc.markContentDirty()(free). DispatchPreviewContentRefreshRequested(debounced) for background refresh of the offstage preview; usePreviewContentChangedonly for eager pushes (toggle, checkbox, locale change, load). Never dispatchPreviewContentChangedinsidebuild(). - Search sync:
_pushPreviewContentand_scheduleLivePreviewRefreshcall_searchController.updateContent(content)when searching. Never callupdateContentfrombuild(). - Preview view key: the page owns
final GlobalKey<SourceMappedMarkdownViewState> _previewViewKey. Bind it withbloc.scrollController.bindView(_previewViewKey)ininitStateand passviewKey: _previewViewKeytoMarkdownPreviewBlocView. Read_previewViewKey.currentState?.currentLineIndexfor preview→editor scroll mapping on toggle. - Toolbar: use
_buildMarkdownBar({required bool enabled})helper for both loading and loaded paths. Never duplicate theMarkdownBar(...)instantiation. - re_editor package: preserve the 2-slot
asStringcache, bounded LRU paragraph cache, binary-search paragraph/chunk lookups, 50 ms highlight debounce, andcloneShallowDirty()contract. Any new mutation path onCodeLinesmust callcloneShallowDirty().
- Keep the layering:
- Does the change affect the calendar/events feature?
- Preserve the current drawer route:
AppDrawercloses the drawer, then callsAppNavigator.toCalendar(context), andtoCalendarmust remain a normalpushso the previous page stays on the navigation stack. - Calendar-specific options (max day bars, holiday set/profile, and any future calendar toggles) live on the dedicated
CalendarSettingsPagereached from the gear icon in the calendar's own app bar (AppNavigator.toCalendarSettings), not on the controls settings page. Do not re-add calendar options toControlsSettingsPage. - Custom calendar events are persisted via
CalendarEventService→CalendarEventDao→calendar_eventsDrift table (schema v15: v10 introduced the table, v11 addedend_date+ time-of-day columns, v12 addeddescription, v13 reworkedpublic_holidays, v14 added the optionalnote_idevent↔note link, v15 added the data-drivencalendar_categoriestable). The recurrence interval ("every N …") shipped with no migration — it is encoded inside the existingrule_payloadJSON.CalendarBlocis constructed via DI with the service and reloads its cache onLoadCalendarEvents. Public holidays live in thepublic_holidaystable seeded byPublicHolidayServicewith add-if-not-exists semantics over a six-year forward window, scoped to the currentHolidayProfile(generic,romania,unitedStates,unitedKingdom,germany,europe,none). - Event categories are data-driven (v15), not an enum.
CalendarEvent.categoryIdis aString; categories live in thecalendar_categoriestable behindCategoryService→CalendarCategoryDao, with the synchronousCalendarCategoriesfacade (constants/calendar_categories.dart) mirroring thePublicHolidayscache pattern (O(1)byId/resolve/all/labelOf/iconFor). Built-ins are seeded insert-if-missing with stable ids equal to the historicalCalendarEventCategoryenum names ('gym', …) which match the strings already incalendar_events.category, so existing events link with no data migration. TheCalendarEventCategoryenum survives only as the built-in seed catalog + the source of localized built-in labels. Unknown ids resolve to a fallback (other) so deleting a category never corrupts its events;CategoryService.deleteCategoryalso reassigns those events tootherin a transaction. Built-ins cannot be deleted. Users create/edit categories viaCategoryEditorSheet(name + icon + color swatches) reached fromCalendarCategoriesPage(AppNavigator.toCalendarCategories, linked fromCalendarSettingsPage) or inline fromCategoryPickerSheet's "Create category" entry. The calendar filter is a hidden-id set (CalendarPageLoaded.hiddenCategoryIds: Set<String>, empty = show all) viaChangeHiddenCategories, so new categories are visible by default. TableCalendar.eventLoadermust stay a pure O(1) lookup throughCalendarBloc.eventsForDay; do not dispatch events, call services, or perform recurrence expansion fromeventLoader.eventsForDayis memoized in a bounded per-day cache (_dayCache, cap 512) that is invalidated only by the handlers which change its inputs (LoadCalendarEvents,CreateCalendarEvent,UpdateCalendarEvent,DeleteCalendarEvent,ChangeHiddenCategories). If you add a handler that mutates events or the category filter, call_invalidateDayCache(); never invalidate from day/focus/format handlers.- Persisted events require a Drift table/migration, DAO, repository, service, backup export/import, and
dart run build_runner build --delete-conflicting-outputsbefore analysis. - User-visible calendar strings must be added to all three ARB files and regenerated with
flutter gen-l10n.
- Preserve the current drawer route:
- Does the change add a new DB-backed singleton service (or convert an existing one)?
- Follow the
DatabaseLifecyclecontract — see the Database Lifecycle section below. Every singleton holding alate AppDatabase _db(or any cached state derived from the DB) MUST expose a staticreset()and register it withDatabaseLifecycle.registerResetHandler(reset)inside itsgetInstance()first-time-init block. Skipping this leaves the singleton bound to a closed database after a multi-database switch.
- Follow the
- Does the change touch app-launch navigation / last-location restore?
- On cold launch the app reopens the last-visited folder (and note) via
AppNavigator.restoreLastLocation(), fired once frommain.dart_checkOnboarding(guarded by_didRestoreLocation, and skipped while onboarding shows).AppNavigator.toFolder/toNoteEditor/toNoteEditorInstantrecord the location throughSettingsService.saveLastFolder/saveLastNote(keyslast_folder_id,last_folder_title,last_note_id). Restore validates existence (FolderStorageService.getFolderById,NoteRepository.getNotesByIds) and clears stale entries; it pushes onto the root navigator so Back still returns home. Keep the existence checks — never navigate to a deleted folder/note.
- On cold launch the app reopens the last-visited folder (and note) via
3. Style Rules To Enforce
- No code comments unless explicitly requested.
- No new tests unless explicitly requested.
- No new markdown documentation files unless explicitly requested.
- Use
AppLocalizations.of(context)!.keyNamefor every user-visible string. - Use existing constants from
lib/constants/(spacing, text styles, icon sizes, settings keys, JSON keys, app constants) instead of magic values. - Prefer compact, touch-friendly Material 3 UI with stable layouts.
Calendar Feature Notes
table_calendar: ^3.2.0 is installed and main.dart initializes locale date formatting with initializeDateFormatting(). The calendar is reachable from the drawer as the first entry (it is a feature, not a setting, so it sits above the settings list and is separated from it by a Divider). AppDrawer closes the drawer first, then calls AppNavigator.toCalendar(context), which must remain a normal push so the previous page stays on the stack. Calendar-specific settings live on their own page, opened from a settings gear in the calendar app bar (AppNavigator.toCalendarSettings).
Status (persisted as of schema v15)
Custom events, user-creatable categories, and public holidays survive hot restart and are included in backup/restore. CalendarBloc depends on CalendarEventService (singleton, in-memory cache backed by the calendar_events table). Public holidays are seeded into the public_holidays table by PublicHolidayService on every startup using insert-if-not-exists scoped to the active HolidayProfile, then mirrored into the static cache consumed by PublicHolidays.isHoliday / PublicHolidays.holidayOn. BackupService round-trips calendar_categories, calendar_events, and public_holidays (backup version: 4; categories import before events so category ids resolve). Old backups (v3 and earlier) still import — missing calendarCategories just leaves the seeded built-ins in place.
Slider label convention: the controls-settings _buildSliderTile takes an optional labelBuilder so only genuinely time-based sliders (auto-save interval) show a s suffix; count sliders (preview lines-per-chunk, calendar max day bars) show the bare number. Never hardcode a unit suffix on a generic slider helper.
Migration timeline:
- v10:
calendar_events+public_holidaysintroduced. - v11:
calendar_events.end_date(nullable inclusive upper bound for recurrences) plusstart_minute/duration_minutescolumns for timed events. - v12:
calendar_events.description(nullable free-form body). - v13:
public_holidaysrebuilt with composite PK(date, name_key)and aprofilecolumn (generic|romania|custom). Migration is an idempotent SQLite table-rebuild guarded byPRAGMA table_info('public_holidays'). - v14:
calendar_events.note_id(nullable link tonotes.id). IdempotentALTER TABLE … ADD COLUMNguarded byPRAGMA table_info('calendar_events').NULL= no linked note. - v15:
calendar_categoriestable (id PK, name, color_value, icon_key, sort_order, is_built_in, created_at, updated_at) for data-driven, user-creatable categories. Created via idempotentCREATE TABLE IF NOT EXISTS; built-ins are seeded byCategoryService(not the migration) with stable ids == the old enum names, socalendar_events.categoryneeds no rewrite. - (no migration): recurrence
intervallives insiderule_payloadJSON; only written when> 1, decoded with a safe fallback to1.
Files and their roles
- lib/pages/calendar_page.dart — host page. Owns
TableCalendar+ day-bars + day-summary panel. Format toggle (month / two-week / week) with localized labels. FAB opens the event editor for the selected day. Long-press on a summary entry edits the event. App bar has a category filter action and a settings gear; the gear pushesCalendarSettingsPage, then on return reloads_maxDayBarsfrom settings and dispatchesLoadCalendarEventsso changed holiday profiles / bar density re-render. - lib/pages/calendar_settings_page.dart — dedicated calendar settings page (
SettingsAppBar(showMenuButton: false)since it's reached from the calendar, not the drawer). Hosts the max day bars slider (SettingsService.getCalendarMaxDayBars/setCalendarMaxDayBars, keySettingsKeys.calendarMaxDayBars, defaultdefaultCalendarMaxDayBars = 3, range 1–6) and the holiday set dropdown (HolidayProfileviaPublicHolidayService.setProfile, optimistic + resync-on-failure). Reset-to-defaults resets both. Add any new calendar option here, mirroring the_buildSectionCard/_buildSliderTilehelpers. - lib/bloc/calendar/calendar_bloc.dart + lib/bloc/calendar/calendar_event.dart — app-level
CalendarBlocregistered in the rootMultiBlocProvider. Sealed events (CalendarPageEvent):LoadCalendarEvents,SelectCalendarDay,ChangeFocusedDay,ChangeCalendarFormat,ChangeHiddenCategories,CreateCalendarEvent,UpdateCalendarEvent,DeleteCalendarEvent.LoadCalendarEventsis the reload/refresh event. Sealed states (CalendarPageState: Initial/Loading/Loaded/Error) withEquatable. Public API:bloc.eventsForDay(day)— amortized O(1) lookup used byTableCalendar.eventLoaderand the day-summary panel (no event dispatch, no service calls, no recurrence expansion fromeventLoader). It memoizes per-day recurrence expansion in a bounded cache invalidated only when events or the category filter change; the previously duplicatedCalendarPageLoaded.selectedEventsgetter was removed so there is one cached expansion path. - lib/models/calendar_event.dart — value object:
id, title, description?, categoryId (String — id of a CalendarCategory), startDate, endDate?, time (EventTime?), rule (RecurrenceRule), noteId (String?), iconKey (String?).allDayis derived astime == null— the persistedall_daycolumn is written from this on save and ignored on read.noteIdis an optional link to a workout note (notes.id); only the id is stored and the folder is resolved at navigation time.copyWithacceptsclearIconKey,clearTime,clearDescription,clearEndDate,clearNoteId(allbool) for explicit nulling.occursOn(day)normalizes both dates to UTC date-only and delegates torule.occursOn, also enforcing the optionalendDateupper bound on the rule. - lib/models/event_time.dart (lives inside
calendar_event.dart) —EventTime { startMinute (0..1439), durationMinutes? (>0) }.endMinutemay exceed1440to model events that cross midnight; presentation/clipping is the caller's responsibility. - lib/models/recurrence_rule.dart — sealed
RecurrenceRule extends Equatablewithbool occursOn(DateTime day, DateTime start). The four periodic rules carry anintervalfield (default1, asserted>= 1);interval == 1short-circuits to the original behaviour. Subclasses (allfinal class):OneTimeRecurrence— single occurrence onstart.DailyRecurrence({int interval})— everyintervaldays on/afterstart((day - start).inDays % interval == 0).WeeklyRecurrence({Set<int> weekdays, int interval})— 1=Mon..7=Sun. Defensive: empty set → false. Withinterval > 1, only matching weeks fire, counted on a fixed Monday-aligned grid (_weekEpoch = 2000-01-03) so A/B splits stay phase-stable regardless of the anchor's weekday.propsincludesweekdays+interval.MonthlyRecurrence({int interval})—day.day == start.dayand whole-month delta% interval == 0; naturally skips months that lack the start day.YearlyRecurrence({int interval})— same month+day and(day.year - start.year) % interval == 0; naturally skips Feb 29 in non-leap years.WorkdaysRecurrence— Mon–Fri AND!PublicHolidays.isHoliday(day).WeekendsRecurrence— Sat–Sun.PublicHolidaysOnlyRecurrence—PublicHolidays.isHoliday(day)only.- Every rule guards
if (day.isBefore(start)) return falsefirst. The fixed cadences (workdays/weekends/holidays) have no interval.
- lib/services/event_time_formatter.dart — pure helper.
EventTimeFormatter.formatRange(time, l10n, localeName)produces a localizedHH:mm – HH:mm(orHH:mm) string; respects 24h/12h locale conventions viaintl. Use everywhere a timed event is rendered (day summary, editor preview, week view). - lib/services/recurrence_formatter.dart — pure helper.
format(rule, l10n, localeName)does a sealed switch and returns a localized, interval-aware string (e.g.Weekly · Mon, Wed, Fri,Every 2 weeks · Mon, Thu,Public holidays only) via the ICU-pluralrecurrenceEvery*keys.weekdayShort(weekday, localeName)uses 2024-01-01 (Monday) as the anchor +DateFormat.E(localeName); never add a 7×3 ARB matrix for weekday names.formatWeekdays(Set<int>, localeName)sorts then comma-joins. - lib/constants/calendar_colors.dart — only the contextual (non-event) colors
CalendarColors.weekend/CalendarColors.publicHoliday. Per-category tints are data-driven (CalendarCategory.color, seeded inCalendarCategories.builtInSeeds); the old per-category constants +forCategoryswitch were removed when categories became data-driven. - lib/constants/calendar_icons.dart — ~60 icons grouped into 10
IconGroupIds:strength, cardio, sports, recovery, body, measurement, achievements, travel, time, generic. API:CalendarIcons.forKey(String?) → IconData?— key → icon lookup (the only resolver; categories and events store aniconKey). Built-in category default icons live inCalendarCategories.builtInSeeds; read paths useCalendarCategories.iconFor(event)(explicit per-event override wins, else the event's category icon). TheforCategoryenum switch was removed with the data-driven refactor.CalendarIcons.groups → List<IconGroup>— ordered list ofIconGroup(IconGroupId id, List<String> iconKeys). Localize group labels via theiconGroup*ARB keys (iconGroupStrength,iconGroupCardio, …) — pick label with a sealedswitchonIconGroupIdin the picker, never via a genericbyKeylookup (none exists).
- lib/constants/public_holidays.dart — sync facade.
PublicHolidays.isHoliday(DateTime)andPublicHolidays.holidayOn(DateTime) → PublicHolidayInfo?consult an in-memory cache populated byPublicHolidayService.getInstance()at startup. UsePublicHolidays.labelOf(info, l10n)for display (handles built-in enum entries and user-added custom labels). ThePublicHolidayenum spans 33 values: 15 generic-Christian feasts (incl. movable Good Friday, Easter Sunday/Monday, Ascension, Pentecost, Whit Monday), 4 Romania-specific entries (unificationDay,childrensDay,stAndrewDay,nationalDayRomania), 9 US federal days (martinLutherKingDay,presidentsDay,memorialDay,juneteenth,independenceDay,laborDayUnitedStates,columbusDay,veteransDay,thanksgiving), 3 UK bank holidays (earlyMayBankHoliday,springBankHoliday,summerBankHoliday),germanUnityDay, andeuropeDay. The companionHolidayProfileenum (generic,romania,unitedStates,unitedKingdom,germany,europe,none) selects which subset gets seeded; helpersprofileNameOf(profile, l10n)andprofileFromName(String?)handle l10n + forward-compat parsing (unknown values fall back togeneric). ThekCustomPublicHolidayKey('custom') is thename_keysentinel for user-added rows;kCustomHolidayProfileKey(also'custom') is theprofilesentinel — they are separate concepts that happen to share a string. Fallback static map covers the fixed-date subset when the cache is empty (tests, pre-init). - lib/widgets/calendar_day_bars.dart — colored bar strip rendered via
TableCalendar.calendarBuilders.markerBuilder. Reads fromDayBarsResolver(provider/resolver pattern inlib/services/day_bars_resolver.dart). Lookup must be O(1) per day; never iterate events inside the marker builder. - lib/services/day_summary_resolver.dart — composes the bottom panel for the selected day. Providers implement
DaySummaryProvider.EventSummaryProvideris the calendar-event implementation:icon: CalendarCategories.iconFor(event)— picks up explicit per-event icon overrides, else the category icon._subtitleFor(event)returnsRecurrenceFormatter.format(...)(when notOneTimeRecurrence) joined withl10n.eventAllDayvia·.- Do not reintroduce the old
_recurrenceLabel(CalendarRecurrence enum)helper — the enum is gone.
- lib/widgets/event_editor_sheet.dart — the create/edit modal sheet. See structure below.
- lib/widgets/icon_picker_sheet.dart — own bottom sheet with
FractionallySizedBox(heightFactor: 0.85),ListView.builderoverCalendarIcons.groups, each section is a localized header +Wrapof 48×48 circular tiles. Selected state:tint.withValues(alpha: 0.18)background + 2px tint border + tint foreground. Call site passes atint(useCalendarCategories.resolve(categoryId).color). - lib/widgets/category_picker_sheet.dart — own bottom sheet for picking a category.
FractionallySizedBox(heightFactor: 0.7),ListView.builderoverCalendarCategories.all(data-driven), each row is aListTilewith the category's tinted circular icon, localized/verbatim label, trailing check mark for the current selection. Returns the selected category id (String). Includes an inline "Create category" entry that opensCategoryEditorSheetand returns the new id. - lib/widgets/category_editor_sheet.dart — create/edit a category (name field, icon via
IconPickerSheet, color via a swatchWrap). Persists throughCategoryService; built-ins keep their localized name (field read-only) but stay color/icon editable. Returns the savedCalendarCategory. - lib/pages/calendar_categories_page.dart — category management list (create via FAB, edit on tap, delete custom rows). Service-direct (
CategoryService), like the holiday settings.
Event editor sheet layout (canonical)
The editor is a FractionallySizedBox(heightFactor: 0.92) bottom sheet built as a single Column:
- Inline header row (NOT a centered title + Divider):
IconButton(close) | Expanded centered title | FilledButton(Save). The Save button lives in the header so the action surface visually belongs to the sheet — do not reintroduce bottom action-bar dividers; they made the actions look detached from the form. Expanded(SingleChildScrollView)body containing, in order:- Title
TextField(autofocus when creating,maxLength: 120). _SectionLabel(eventType)+_PickerTileopeningCategoryPickerSheet. Category is a picker tile, not a chip Wrap — it is a one-of-N selection so it belongs in a dialog._SectionLabel(iconLabel)+_PickerTileopeningIconPickerSheet. Trailing ischevron_rightwhen no override is set, or a resetIconButtonwhen there is one._SectionLabel(eventDate)+_PickerTileopeningshowDatePicker(±20 years). Subtitle says "Starts on this date" when recurring._SectionLabel(repeatMode)+ centeredSegmentedButton<_RepeatMode>withoneTime/recurringoptions (looks_one_rounded/repeat_roundedicons).- If recurring:
_SectionLabel(frequency)+Wrapof 7ChoiceChips over_RecurrenceKind.values(daily, weekly, monthly, yearly, workdays, weekends, holidays). - If recurring AND the kind supports an interval (daily/weekly/monthly/yearly): a
_IntervalStepper(− / N / +) with a localized unit label (recurrenceUnit*), clamped to1.._maxInterval(99)._kindSupportsInterval/_intervalUnitLabelgate it; fixed cadences (workdays/weekends/holidays) hide it. - If recurring AND weekly:
_SectionLabel(weekdays)+Wrapof 7FilterChips usingRecurrenceFormatter.weekdayShort(w, localeName). If the set becomes empty, showweeklyDaysHintinerrorcolor and_canSavedisables Save. - Optional time-of-day section: an "All-day" switch; when off, start/end time
_PickerTiles backed byEventTime(end may cross midnight). - Optional description
TextField(maxLength: 500). - Linked-note
_PickerTile: opensNotePickerDialogto attach a workout note; trailing reset clears it. The current title is resolved viaNoteRepository.getNotesByIds([id])so soft-deleted notes resolve to empty (never a ghost title). - If editing: trailing
TextButton.icon(delete)in error color inside the scrollable body (not in a bottom action bar). Tapping shows anAlertDialogusingdeleteEventConfirm(title).
- Title
Private widgets in the editor file:
_SectionLabel— labelLarge / onSurfaceVariant / paddingEdgeInsets.fromLTRB(0, 16, 0, 8)._PickerTile—Card(margin: zero) + ListTile(leading, title, subtitle?, trailing ?? chevron_right, onTap).
Private enums:
enum _RepeatMode { oneTime, recurring }enum _RecurrenceKind { daily, weekly, monthly, yearly, workdays, weekends, holidays }
State + behavior rules to preserve:
_initRecurrenceFrom(RecurrenceRule)does a sealed switch to rehydrate_mode,_kind,_weekdaysfrom an existing event._buildRule()constructs the concreteRecurrenceRuleat save time. Always wrap the weekday set withSet.unmodifiable(...)._canSave= title non-empty AND (not weekly OR weekdays non-empty)._pickDatere-anchors_weekdaysto the new date's weekday only when the previous selection was the implicit default (length == 1 && first == old.weekday). Explicit multi-day selections are preserved._pickIcon/_pickCategoryearly-return on!mountedafterawait._onSavebuilds a brand-newCalendarEventon create (withUuid().v4()); on edit, callscopyWith(... clearIconKey: _iconKey == null)so explicit-null overrides actually clear._titleControlleris owned by theStatefulWidgetand disposed indispose().
Recurrence semantics (must stay consistent)
- One time: only on
start. - Daily: every
intervaldays on/afterstart(default every day). - Weekly: any day whose
weekdayis in the user-selected set; withinterval > 1only every Nth week (fixed Monday-aligned grid) fires. Empty set → no occurrences (UI prevents saving). - Monthly: same day-of-month, every
intervalmonths; short months are silently skipped (Jan 31 → no Feb 31 occurrence). - Yearly: same month and day, every
intervalyears; Feb 29 events only fire in leap years. - Workdays: Mon–Fri AND not a public holiday. This is semantic "working day" — do not relax it to "Mon–Fri inclusive of holidays".
- Weekends: Sat–Sun only.
- Public holidays only: uses
PublicHolidays.isHoliday(day). Does not require the day to matchstartin any other way. - Interval applies only to daily/weekly/monthly/yearly. All occurrence math is O(1) modular arithmetic on date-only UTC values — keep it that way; no per-day allocation, no DST-sensitive
DateTimemath.
l10n keys owned by this feature
Calendar / event editor / pickers:
addEvent, editEvent, deleteEvent, deleteEventConfirm, eventTitle, eventType, eventDate, eventAllDay, eventAllDayHint, eventStartTime, eventEndTime, eventEndTimeNone, eventEndTimeHint, eventTimeSection, eventCrossesMidnight, eventDescription, eventDescriptionHint, eventUntilLabel, eventUntilNone, eventUntilHint, save, cancel, delete, recurrence, recurrenceDaily, recurrenceWeekly, recurrenceMonthly, recurrenceYearly, recurrenceWorkdays, recurrenceWeekends, recurrenceHolidaysOnly, repeatMode, repeatOnce, repeatRecurring, frequency, weekdays, weeklyDaysHint, startsOn, iconLabel, iconDefault, iconCustom, pickIcon, pickCategory, resetToDefault
Recurrence interval (ICU plural, {count}; Romanian also needs the few form):
recurrenceEveryDays, recurrenceEveryWeeks, recurrenceEveryWeeksOn ({count},{days}), recurrenceEveryMonths, recurrenceEveryYears, recurrenceIntervalLabel, recurrenceIntervalIncrement, recurrenceIntervalDecrement, recurrenceUnitDays, recurrenceUnitWeeks, recurrenceUnitMonths, recurrenceUnitYears
Linked note (v14):
eventLinkedNote, eventLinkNoteHint, eventLinkedNoteMissing, eventOpenLinkedNote, eventRemoveNoteLink
Calendar settings page:
calendarSettings (page title + app-bar gear tooltip), calendarSection, calendarMaxDayBars, calendarMaxDayBarsDesc ({count}), holidayProfileTitle, plus the shared resetToDefaults / settingsReset keys.
Category labels (one per built-in CalendarEventCategory; custom categories show their stored name verbatim):
eventCategoryGym, eventCategoryCardio, eventCategoryRest, eventCategoryHoliday, eventCategoryCompetition, eventCategoryMeasurement, eventCategoryMobility, eventCategoryBirthday, eventCategoryOther
Selecting the birthday built-in (kBirthdayCategoryId) on a still-one-time event in the editor pre-fills a yearly recurrence (it never overrides a recurrence the user already configured).
Category management (v15):
calendarCategories, calendarCategoriesDesc, createCategory, editCategory, categoryName, categoryNameHint, categoryColor, categoryDefault, deleteCategory, deleteCategoryConfirm ({name}), categoryDeleted
Icon group labels (one per IconGroupId):
iconGroupStrength, iconGroupCardio, iconGroupSports, iconGroupRecovery, iconGroupBody, iconGroupMeasurement, iconGroupAchievements, iconGroupTravel, iconGroupTime, iconGroupGeneric
recurrenceWeeklyOn was removed in v14 — the interval-aware recurrenceEvery* keys replace it. Keep the {count} / {days} plural metadata intact across all three ARBs.
Persistence layout (schema v15)
lib/database/tables/calendar_categories_table.dart—CalendarCategories(tablecalendar_categories):id (PK), name, colorValue (ARGB int), iconKey, sortOrder, isBuiltIn, createdAt, updatedAt. Built-ins seeded with stable ids ==CalendarEventCategory.name.lib/database/daos/calendar_category_dao.dart—getAll,insertIfMissing(seed),insertCategory,updateCategory(maskscreatedAt),deleteById,deleteAll,nextSortOrder.lib/services/category_service.dart— singleton (DatabaseLifecyclereset). Seeds built-ins, loads + publishes theCalendarCategoriescache, owns create/update/delete (+ event reassignment on delete) and backup export/import.lib/constants/calendar_categories.dart— the synchronous facade (cache +builtInSeeds+labelOf/iconFor/resolve/fallback).CalendarEventDao.reassignCategory(from,to)moves events when a category is deleted.lib/database/tables/calendar_events_table.dart—CalendarEvents:id (PK), title, description? (v12), category (TEXT — stores theCalendarCategory**id**; built-ins use the enum name likegym, customs use a UUID; legacy column name kept), startDate (date-only UTC), endDate? (v11, inclusive upper bound for recurring rules), allDay, startMinute? / durationMinutes? (v11; sourced fromEventTime), noteId? (v14, link to notes.id), iconKey?, ruleKind, rulePayload? (JSON:weekdaysand/orintervalwhen > 1), createdAt, updatedAt.lib/database/tables/public_holidays_table.dart—PublicHolidaysTable(table namepublic_holidays): composite PK(date, name_key)pluscustomLabel?andprofile(default'generic'). Built-in rows carrynameKey == PublicHoliday.nameandprofile ∈ {generic, romania, unitedStates, unitedKingdom, germany, europe}; user-added rows usenameKey == kCustomPublicHolidayKey('custom') andprofile == kCustomHolidayProfileKey('custom'). Switching profiles deletes only rows matching the previous profile — customs are preserved by virtue of carrying their own profile tag.lib/database/daos/calendar_event_dao.dart—getAll,upsert(CalendarEventsCompanion),deleteById,deleteAll.lib/database/daos/public_holiday_dao.dart—getAll,insertIfMissing({date, nameKey, profile, customLabel?}) → bool(usesInsertMode.insertOrIgnore),deleteOn(date),deleteProfile(profile),deleteAll.lib/services/calendar_event_service.dart— singleton (getInstance()pattern). Owns row↔domain mapping including sealed-RecurrenceRuleJSON serialization ({kind, weekdays?, interval?}—intervalonly written when> 1, decoded with a clamped fallback to1) andEventTime/endDate/noteIdround-trips. Exposes synchronouseventsgetter used byCalendarBloc.lib/services/public_holiday_service.dart— singleton. Reads the activeHolidayProfilefrom settings ongetInstance(), then seeds defaults for the current year + 5 forward years.setProfile(next)is transactional:deleteProfile(prev) → write setting → seed(next)inside_db.transaction, then_load()after commit so the static cache only republishes a coherent state. Per-profile seed builders (_genericSeeds,_romaniaSeeds,_unitedStatesSeeds,_unitedKingdomSeeds,_germanySeeds,_europeSeeds) are pure static functions;nonereturnsconst []. Movable Christian feasts use Meeus Anonymous Gregorian for Catholic Easter and Meeus Julian + dynamic Julian→Gregorian offset (_julianToGregorianOffset, constant 13 across 1900–2099, formula extends symbolically beyond) for Orthodox Easter. US/UK movable days (nth-Monday/Thursday, last-Monday) come from the_nthWeekdayOfMonth/_lastWeekdayOfMonthhelpers. Verified against published Orthodox Easter dates 2023–2030.- DI registration in
lib/core/di/injection.dart:CategoryService,PublicHolidayService, andCalendarEventServiceare all awaited in_registerServices(categories before events);CalendarBlocis a factory takingservice: getIt<CalendarEventService>().main.dartusesgetIt<CalendarBloc>()..add(const LoadCalendarEvents()). - Backup:
BackupServiceexports both tables. Public-holiday rows includeprofile; old backups missing the field are imported as'generic'(matches the v12→v13 back-fill rule).
When adding a holiday profile, the change set is: new enum value in HolidayProfile, new entry in profileNameOf switch, new holidayProfile<Name> ARB key trio, a new _<name>Seeds(year) builder dispatched from _buildSeeds; any new holiday it introduces also needs a PublicHoliday enum value + nameOf branch + publicHoliday<Name> ARB trio. The dropdown auto-enumerates HolidayProfile.values, so the new item appears automatically. No schema change needed. Existing built-in profiles: generic (Western Christian), romania, unitedStates, unitedKingdom, germany (federal nationwide only), europe (combined pan-European + Europe Day).
Database Lifecycle (multi-database safety)
The app supports switching between multiple local databases on the database settings page. Every DB-backed singleton (late AppDatabase _db, cached rows, derived state) holds references that become stale when the active database changes. lib/database/database_lifecycle.dart provides the registry that keeps this correct-by-construction so we no longer rely solely on the "restart required" dialog to mask stale state.
The contract for any DB-backed singleton
Every service that caches DB-derived state MUST:
- Expose
static void reset() { _instance = null; ... }that nulls the singleton and cancels any timers/streams it owns. - Call
DatabaseLifecycle.registerResetHandler(reset)inside thegetInstance()first-time-init block — after the instance is fully constructed and assigned to_instance(so a handler firing mid-init can't observe a half-built service). - If the service publishes data into a separate static cache (like
PublicHolidayServicedoes withPublicHolidays._cache),reset()must also clear that static cache. Otherwise the synchronous facade keeps returning the previous database's data until the new instance republishes.
The canonical pattern:
static Future<MyService> getInstance() async {
if (_instance == null) {
_instance = MyService._();
_instance!._db = await AppDatabase.getInstance();
await _instance!._load();
DatabaseLifecycle.registerResetHandler(reset);
}
return _instance!;
}
static void reset() {
_instance = null;
}
Services currently following this contract: CounterService, CalendarEventService, CategoryService, PublicHolidayService, MarkdownBarService, SettingsService, DevOptionsService, BackupService, NotePositionService. New singletons must join this list.
Where the hook fires
AppDatabase.getInstance({databaseName}) calls DatabaseLifecycle.notifyDatabaseSwitching() immediately before closing the previous _instance. AppDatabase.deleteAllData() does the same. The registry is self-clearing (handlers fire once per notify, then the list empties) so re-registration on the next getInstance() is the only way handlers stay subscribed — there is no unregister. The restart-required dialog after a manual DB switch stays in place because BLoC instances held by widgets still need a process restart to rebind; the lifecycle hook makes correctness independent of that dialog.
Anti-patterns
- Holding a
late AppDatabase _dbin a singleton without areset()+ registration. This is a latent crash-after-switch. - Caching DB data in a top-level
staticvariable without a way to clear it from the owning service'sreset(). Mirror thePublicHolidays/PublicHolidayServicepairing. - Registering a handler from a constructor that runs more than once per singleton lifetime — it duplicates. Always register from the
getInstance()if (_instance == null)branch.
Hard rules for calendar work
TableCalendar.eventLoaderstays a pure O(1) lookup throughCalendarBloc.eventsForDay(memoized per day; cache invalidated only when events or the category filter change). No event dispatch, no service calls, no on-the-fly recurrence expansion inside the loader.- Don't reintroduce the dropped
CalendarRecurrenceenum orevent.recurrencefield — they are gone for good. Always useevent.rule. - Don't read
event.allDaydirectly to decide whether to render a time row — checkevent.time != null. The boolean is a derived persistence detail. - Don't add a generic
AppLocalizations.byKey— pick localized strings via sealedswitchonCalendarEventCategory/IconGroupId/ rule type /HolidayProfile. - Don't render category icons/colors by hand on read paths — use
CalendarCategories.iconFor(event)for the icon (explicit per-event override wins, else the category's icon) andCalendarCategories.resolve(event.categoryId).colorfor the tint. An unknown id resolves to theotherfallback, never throws. - Don't put the editor's Save/Cancel in a bottom action bar with dividers — they belong in the inline header row. Delete (when editing) lives at the bottom of the scrollable body, not in a footer.
- Category selection is a
_PickerTile→CategoryPickerSheet, never a chipWrap. Recurrence frequency stays as chips (the choice space is small and benefits from at-a-glance comparison). - When adding a date picker anywhere in calendar code, use
DateTime.utc(y, m, d)for normalization to matchCalendarEvent.occursOn. - When mutating
public_holidays, never bypassPublicHolidayService— the staticPublicHolidays._cacheis rebuilt only by_load(), so direct DAO writes will desync the sync facade until the next app start. - Resolve a linked note (
event.noteId) viaNoteRepository.getNotesByIds([id]), notgetNoteById— the former filtersis_deleted, so a soft-deleted note yields an empty list and you showeventLinkedNoteMissinginstead of opening a ghost.getNoteByIdis cache-based and does not filter deletes. - Keep the recurrence
intervalinrule_payloadJSON (write only when> 1, decode with a clamped fallback to1). Do not add a dedicatedintervalcolumn — it intentionally needs no migration.
4. Validation Commands (PowerShell On Windows)
Run only what the change requires.
dart analyze lib
flutter gen-l10n
dart run build_runner build --delete-conflicting-outputs
flutter run
Helper scripts:
.\build_release.bat arm64
.\install_to_device.bat arm64
.\generate_drift.bat
Typical mapping:
- UI-only Dart change:
dart analyze lib. - ARB / l10n change:
flutter gen-l10n, thendart analyze lib. - Drift table / DAO / migration change:
dart run build_runner build --delete-conflicting-outputs, thendart analyze lib.
5. When To Ask Vs Act
- Act without asking when the request is concrete, scoped, and matches existing patterns.
- Ask only when a change would alter persisted data shape in a non-backward-compatible way, change backup format semantics, or introduce a new architectural pattern (new state-management lib, new persistence layer, new navigation strategy).
6. Anti-Patterns To Avoid
- Hand-editing
lib/database/database.g.dartor generatedapp_localizations_*.dartfiles. - Resetting or wiping the local database to "fix" schema drift instead of writing a migration.
- Adding raw
SharedPreferenceskeys instead of going throughSettingsService+SettingsKeys. - Hardcoding user-facing strings in Dart instead of using ARB +
AppLocalizations. - Introducing heavy work on the editor's hot path (per-keystroke string copies, synchronous DB writes, expensive rebuilds).
- Adding decorative UI churn that causes layout shifts in the editor, toolbar, counters, or folder/note lists.
- Calling
_searchController.updateContentor anyChangeNotifier.notifyListeners-triggering method insidebuild(). - Dispatching
PreviewContentChangedeagerly on every keystroke — usemarkContentDirty+PreviewContentRefreshRequestedfor background/debounced refreshes. - Creating an anonymous
GlobalKey<SourceMappedMarkdownViewState>()inline — always hold it as a named page field so preview→editor scroll mapping andviewKey:wiring both reference the same instance. - Duplicating the
MarkdownBar(...)widget tree; use_buildMarkdownBar({required bool enabled}). - Bypassing
cloneShallowDirty()when adding new mutation paths toCodeLinesin the re_editor package.