Repository navigation
Architecture
A walk-through of how the codebase is organised and how a typical user action flows from a tap to Firestore and back.
This is a single-module Android application. The root project (Varisankya) contains exactly one module: :app.
varisankya-android/
├── settings.gradle.kts # rootProject.name = "Varisankya"; include(":app")
├── build.gradle.kts # Top-level plugin declarations (apply false)
├── gradle/libs.versions.toml # Version catalogue
└── app/
├── build.gradle.kts # Module config — android {}, plugins, deps, GPP block
└── src/main/...
There is no library-module split. If the codebase grows, a future :core module for the data layer would be the natural first carve-out.
MVVM, with one ViewModel and view-XML layouts. No Compose, no Hilt/DI, no Navigation component.
| Layer | Lives in | What it does |
|---|---|---|
| UI |
*.kt activities + bottom_sheet_* fragments + XML layouts |
findViewById + manual binding (no view binding generation enabled per class) |
| ViewModel | viewmodel/MainViewModel |
Holds StateFlow<List<Subscription>> and StateFlow<HeroState>; subscribes to Firestore via a snapshot listener; orchestrates writes (markAsPaid, updateSubscriptionStatus, deleteSubscription) |
| Repository / helpers |
util/PaymentRepository, util/DateHelper, etc. |
Centralised Firestore paths, recurrence math, currency formatting |
| Data |
Subscription.kt, PaymentRecord.kt
|
Firestore-mapped data classes |
| Background |
SubscriptionNotificationWorker (WorkManager) + receiver/NotificationActionReceiver (broadcast) |
Daily reminder notifications + the "Mark Paid" action button |
| Screen | Class | Layout | Notes |
|---|---|---|---|
| Home | MainActivity |
activity_main.xml |
Hero card, subscription list, Extended FAB, swipe-to-mark-paid |
| All Payments / History | UnifiedHistoryActivity |
activity_unified_history.xml |
Hero with avg-monthly (Tukey IQR), drill-down column chart, list |
| Search | SearchActivity |
activity_search.xml |
Full-screen search with filter chips |
| Settings | SettingsActivity |
activity_settings.xml |
Collapsing large-app-bar; cards for Currency / Security / Appearance / Typography / Notifications / Haptics / Legal |
| Login screen (overlay on Home) | (inside MainActivity) |
activity_main.xml (the login_container) |
Shown when auth.currentUser == null
|
Bottom sheets (DialogFragment subclasses):
| Sheet | Class | Layout | Trigger |
|---|---|---|---|
| Add / Edit subscription | AddSubscriptionBottomSheet |
bottom_sheet_add_subscription.xml |
FAB tap (Add); subscription tap (Edit) |
| Manage Payments | PaymentBottomSheet |
bottom_sheet_payment.xml |
"Manage Payments" button in Edit sheet |
| Generic confirmation | ConfirmationBottomSheet |
bottom_sheet_confirmation.xml |
Swipe-to-paid, delete sub, pause sub |
| Generic selection | SelectionBottomSheet |
bottom_sheet_selection.xml |
Recurrence picker, category picker, currency picker |
| About | AboutBottomSheet |
bottom_sheet_about.xml |
Settings → About |
| History cleanup | CleanupBottomSheet |
bottom_sheet_cleanup.xml |
Auto-shown by All Payments when invalid records are detected |
BaseActivity is the common parent that applies the theme + transition setup; every concrete activity extends it.
What happens when a user swipes a subscription right to mark it paid:
1. User swipes right on item_subscription in subscriptions_recycler_view
└─ ItemTouchHelper → SwipeActionCallback.onSwipeRight (util/SwipeHelpers)
2. SubscriptionActionHelper.confirmMarkAsPaid()
├─ Analytics.paymentMarkPaidSwipe() ← telemetry
└─ Shows ConfirmationBottomSheet ("Mark Paid?")
3. User taps Confirm
└─ viewModel.markAsPaid(subscription, onSuccess, onError)
4. MainViewModel.markAsPaid()
├─ Builds PaymentRecord(date=now, amount, currency, …)
├─ Computes nextDueDate via DateHelper.calculateNextDueDate()
├─ Firestore batch:
│ set(users/{uid}/subscriptions/{sid}/payments/{pid}, payment)
│ update(users/{uid}/subscriptions/{sid}, dueDate=next)
├─ On batch success:
│ PaymentRepository.mirrorPaymentToFlat(...) ← dual-write to flat
│ SubscriptionNotificationWorker.cancelFor() ← clear OS notification
│ onSuccess()
5. Firestore snapshot listener in MainViewModel fires
└─ _subscriptions emits new list
└─ HomeScreen reactively updates (hero recalculates, list refreshes)
Notable invariants:
- The dual-write to the flat collection is best-effort — if it fails (e.g. Firestore rules don't allow), the legacy nested write is still authoritative.
- Notification cancel happens client-side after the Firestore write — both paths (in-app and notification-action) converge on the same
SubscriptionNotificationWorker.cancelFor()helper so the notification-ID scheme stays in one place.
SubscriptionNotificationWorker is a CoroutineWorker scheduled via a chained one-shot pattern (v3.8-beta.13+) — every run schedules the next one from the current wall-clock time, so the worker is self-correcting against timezone travel and DST:
SubscriptionNotificationWorker.scheduleNext(context, replacing)
// enqueues a OneTimeWorkRequest under unique-work-name
// "subscription_notifications". delayMs is computed in Calendar fields
// (HOUR_OF_DAY / MINUTE) anchored to the device's CURRENT local zone, so
// there is no absolute UTC instant baked in.
// Call sites:
// MainActivity.setupNotifications → replacing=false, KEEP policy
// SettingsActivity.rescheduleNotifications → replacing=true, REPLACE
// SubscriptionNotificationWorker.doWork end → replacing=true (self-chain)
// SystemEventReceiver (ACTION_TIMEZONE_CHANGED / ACTION_TIME_SET) → REPLACE
SystemEventReceiver listens for ACTION_TIMEZONE_CHANGED and ACTION_TIME_SET (both protected system broadcasts) and immediately re-anchors the worker schedule so the user doesn't even see one drifted notification at the moment of travel.
The worker:
- Reads all active subscriptions for the signed-in user.
- Computes days-left for each (both
LocalDate.now()and the parsed due-date are computed in UTC — see the design invariant inAGENTS.md). - If
daysLeft in 0..NotificationDays(user-configurable in Settings), posts a notification with:- Title:
Due Today/Due Tomorrow/Due in N days - Message:
[Autopay • ]<subscription.name>: <currency> <cost> -
Mark Paid action →
NotificationActionReceiver.ACTION_MARK_PAID→ marks paid + cancels notification + mirrors to flat collection -
Body tap → opens
MainActivitywithEXTRA_FROM_NOTIFICATION=true(used to lognotification_tap) -
Swipe-away →
NotificationActionReceiver.ACTION_NOTIFICATION_DISMISSED(used to lognotification_dismiss)
- Title:
- Logs
notification_worker_run(withsubscriptions_checked/notifications_postedcounts) andnotification_postedper actual posting — see Analytics & Audit.
Channel. Notifications use channel id subscription_reminders_v3 with IMPORTANCE_DEFAULT. The channel id has been bumped twice — Android caches per-channel importance, so changing the level requires recreating with a new id. Legacy subscription_reminders / _v1 / _v2 channels are deleted on first worker run.
Notification ID scheme. SubscriptionNotificationWorker.notificationIdFor(subscriptionId) = subscriptionId.hashCode(). Stable across worker runs so re-firing overwrites rather than stacks.
The worker runs daily at the user's chosen reminder time (Settings → Notifications → Daily Reminder). A user-triggered diagnostic — Settings → Notifications → Send Test Notification — calls SubscriptionNotificationWorker.postTestNotification(context) to post a sample notification immediately, bypassing the worker entirely.
-
Grouped list shapes.
SubscriptionAdapterapplies differentShapeAppearanceModels based on position (first,middle,last,single). First and last items have large outer corners and small inner corners; middle items have small corners all around. Visually creates a single "grouped" surface from N cards. -
Extended FAB shrink-on-scroll.
MainActivity's NestedScrollView scroll listener callsfab.shrink()/fab.extend()based on scroll direction — standard Pixel pattern. The FAB's colour pair (colorPrimaryContainer/colorOnPrimaryContainer) is applied programmatically inMainActivity.applyFabColours()because M3's ExtendedFAB style silently swallowsandroid:textColorset in XML — see the design invariant inAGENTS.md. -
Sticky-resistance swipe-to-mark-paid.
util/SwipeHelpers.applyStickyDamping()applies a non-linear damping curve to the visual translation: within the first 15 % of the view width the item only follows at ~45 % of finger movement (the "stick"), past that threshold it follows 1:1 carrying the accumulated offset. Two haptic events bracket the release point. Mirrors the Android notification-swipe feel. -
CollapsingToolbarLayout in Settings. Title is Display-sized at rest, scales+pins on scroll. Owned by XML's
app:title. -
Bottom sheet drag handle haptics.
BottomSheetDialog's drag handle getsperformHaptics(CLOCK_TICK)on drag-state changes for tactile feedback. -
Material 3 chips everywhere with the project-wide
Widget.App.Chipstyle for consistent selected/unselected treatment.
-
No Compose. All UI is view-XML +
findViewById. View Binding generation is enabled in the build but most files don't use the generated class — adapters and activities usefindViewByIddirectly. - No DI framework (Hilt / Koin). The app is small enough that direct construction is fine.
-
No Navigation component. Activities navigate via plain
Intent; bottom sheets are shown viaDialogFragment.show(). - No Room. All persistence is Firestore. Local cache is whatever the Firestore SDK does internally.
- No analytics events with high cardinality — scalar params only; merchant strings, amounts, IDs are not logged. See Analytics & Audit.
- Data Model & Migrations — the Firestore collections and the dual-write migration.
- Firebase Setup — Auth + Firestore rules + Analytics linking.
- Design System — themes, typography, shapes, icons.
Source: aarshps/varisankya-android · This wiki is human-facing; the repo itself targets AI coding agents (AGENTS.md).