diff --git a/.github/workflows/release-internal.yml b/.github/workflows/release-internal.yml new file mode 100644 index 00000000..1ad1cb06 --- /dev/null +++ b/.github/workflows/release-internal.yml @@ -0,0 +1,79 @@ +name: Release Internal Testing AAB + +on: + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + java-version: '21' + distribution: 'adopt' + + - name: 📚 Git Checkout + uses: actions/checkout@v5 + + - name: 🐦 Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: 3 + + - name: 📦 Install Dependencies + run: flutter packages get + + - name: ✨ Check Formatting + run: dart format --set-exit-if-changed lib test + + - name: ⚙️ Code Generation + run: dart run build_runner build --delete-conflicting-outputs + + - name: 🕵️ Analyze + run: flutter analyze lib + + - name: ⚙️ Download Android keystore + id: android_keystore + uses: timheuer/base64-to-file@v1.2 + with: + fileName: key.jks + encodedString: ${{ secrets.PROD_KEYSTORE }} + + - name: 🔐 Create key.properties + run: | + echo "storeFile=${{ steps.android_keystore.outputs.filePath }}" > android/key.properties + echo "storePassword=${{ secrets.PROD_KEYSTORE_PASSPHRASE }}" >> android/key.properties + echo "keyPassword=${{ secrets.PROD_KEYSTORE_PASSWORD }}" >> android/key.properties + echo "keyAlias=${{ secrets.PROD_KEYSTORE_ALIAS }}" >> android/key.properties + + - name: ⚙️ Build AAB + run: flutter build appbundle --flavor production --target lib/main_production.dart -v + + - name: ⚙️ Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.0" + bundler-cache: true + + - name: ⚙️ Cache bundle dependencies + uses: actions/cache@v4 + with: + path: vendor/bundle + key: ${{ runner.os }}-gems-${{ hashFiles('**/Gemfile.lock') }} + restore-keys: ${{ runner.os }}-gems- + + - name: ⚙️ Download bundle dependencies + run: | + gem install bundler:2.0.2 + bundle install + + - name: 🚀 Release to Google Play (Internal testing) + env: + SUPPLY_PACKAGE_NAME: ${{ secrets.ANDROID_PACKAGE_NAME }} + SUPPLY_JSON_KEY_DATA: ${{ secrets.PLAY_STORE_CREDENTIALS }} + run: | + bundle exec fastlane supply \ + --aab build/app/outputs/bundle/productionRelease/app-production-release.aab \ + --track internal diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..24e57ed2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,70 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Keep `docs/` current — this is required, not optional + +This repo has a `docs/` directory (see `docs/README.md` for the index) written for AI agents: +architecture, data layer, features, routing, auth, networking, notifications, and CI/build details, +each with file:line citations. **Any change you make that invalidates something described there must +update the relevant doc file in the same commit/PR** — a new repository or Cubit, a changed fetch or +auth pattern, a new/removed route, a CI workflow edit, a new feature module, a dependency-injection +change, and so on. Treat a stale doc as a bug in your own change, not a separate follow-up task. + +Before finishing any non-trivial change, ask: "does anything in `docs/` now describe this incorrectly?" +If yes, fix it as part of the same change. If you notice a doc is already stale for an unrelated reason +while working on something else, fix that opportunistically too rather than leaving it. + +## Commands + +```bash +flutter packages get # install dependencies +dart run build_runner build --delete-conflicting-outputs # regenerate freezed/json_serializable/isar/hive_ce/injectable code (required after touching any model, repository, or DI annotation) +dart format --set-exit-if-changed lib test # format check (CI-enforced) +flutter analyze lib # static analysis — CI treats ANY warning as fatal (exit 1), not just errors +flutter run --flavor development --target lib/main_development.dart # run dev flavor +flutter run --flavor staging --target lib/main_staging.dart # run staging flavor +flutter run --flavor production --target lib/main_production.dart # run production flavor +flutter build apk --flavor --target lib/main_.dart # build APK for a flavor +``` + +There is no `test/` directory with any test files yet — `flutter test` has nothing to run. CI (`pr-check.yml`) only gates on format + codegen + `flutter analyze`, run against PRs targeting `main` (not against `feat/*` branches directly — see Architecture below for why that matters). + +`android/key.properties` is required locally to build (points at a shared public dev keystore for Firebase social-auth SHA1 matching) — see README.md "APK Signing" for the exact contents to create. + +Gradle/AGP/Kotlin versions are pinned tightly together (`android/gradle/wrapper/gradle-wrapper.properties`, `android/settings.gradle`): AGP's minimum-supported Gradle version has been the recurring blocker when bumping either — check AGP's release notes for its Gradle floor before bumping one without the other. + +## Architecture + +### CI targets `main`, not the working branch + +`.github/workflows/pr-check.yml`, `release-apk.yml`, and `release.yml` only trigger on `pull_request`/`release` events aimed at **`main`**. If active development happens on a long-lived branch (e.g. `feat/2026_rebrand`) with PRs stacked on top of *that* branch instead of `main`, none of those PRs get CI-checked — only the eventual PR from that branch into `main` does. When diagnosing "CI is failing," first confirm which PR the check actually ran on. + +### Dependency injection: `injectable` + `get_it`, singletons only + +`lib/core/di/injectable.dart` calls a generated `getIt.initGetIt()` (`@InjectableInit`). Every `@singleton`-annotated repository/service (`ApiRepository`, `AuthRepository`, `DBRepository`, `FirebaseRepository`, `HiveRepository`, `ShareRepository`, `NotificationService`, `FlutterConRouter`) is eagerly constructed with no injected constructor args — each wires its own dependencies internally. There are no `@injectable` (factory) registrations. **Cubits are not part of the DI graph at all** — they're constructed by hand in `lib/bootstrap.dart` (pulling repositories via `getIt()`) and provided app-wide through one large `MultiBlocProvider`. Adding a new Cubit means adding it to that list, not annotating it. + +### Two local persistence stores with different jobs + +- **Isar** (`isar_community`, `lib/common/repository/db_repository.dart`, models in `lib/common/data/models/local/*.dart`) is the queryable offline content cache: feed entries, speakers, sessions, sponsors, organisers. Used because it supports `.filter()` queries for search/browsing. +- **Hive** (`hive_ce`, `lib/common/repository/hive_repository.dart`, `lib/common/data/models/adapters.dart`) is the small key-value store: auth token, user profile, theme mode preference. Keyed to a per-flavor box name (`FlutterConValues.hiveBox`, e.g. `fluttercon-dev`). + +Logout (`LogOutCubit`) wipes both: all Isar tables via `DBRepository.clearAllTables()`, and the token/profile/sessions keys via `HiveRepository.clearPrefs()`. + +### Fetch pattern: cache-first, silent background refresh + +Cubits that load list data (sessions, speakers, sponsors, organisers, feed) follow the same shape: if Isar already has data, emit `Loaded` from it immediately and kick off a network refresh in the background *without re-emitting* — the UI won't show newly-fetched data until the next explicit fetch. If Isar is empty (or `forceRefresh: true`), fetch from `ApiRepository` first, persist to `DBRepository`, then read back and emit. `SearchCubit` never touches the network — it's pure Isar filter queries. This means "data looks stale" bugs are usually about this silent-refresh path, not a caching bug per se. + +### Routing has no auth guard + +`lib/common/utils/router.dart` (go_router) declares routes but no `redirect:` logic. `lib/features/splash/splash_screen.dart` unconditionally navigates to the dashboard (its own comment notes auth-redirect was intentionally bypassed) — sign-in is reached manually, not enforced. Auth state lives in Hive and is only consulted by things that need a token (e.g. `NetworkUtil` attaching `Authorization: Bearer`, `BookmarkSessionCubit` gating on `HiveRepository.retrieveToken()`), not by routing. + +The dashboard's bottom-nav (Home/Feed/Sessions/About) is a plain `PageView` inside `DashboardScreen`, not a go_router `ShellRoute` — tab switches don't change the URL/route stack. + +### Flavors + +Three entrypoints (`lib/main_development.dart`, `lib/main_staging.dart`, `lib/main_production.dart`) each construct a `FlutterConConfig` singleton (`lib/common/utils/env/flavor_config.dart`) with flavor-specific `baseDomain`, `hiveBox`, `eventSlug`, `organiserSlug` before calling `bootstrap()`. `NetworkUtil` reads the active config for its base URL. All three CI/release workflows only ever build the `production` flavor — `staging`/`development` are local-only. + +### Networking accepts any TLS certificate + +`lib/common/utils/network.dart`'s `badCertificateCallback` always returns `true`. Be aware of this if working on anything network-security-adjacent; it's an existing behavior, not something to silently "fix" as a drive-by in an unrelated change. diff --git a/analysis_options.yaml b/analysis_options.yaml index 53e334e0..e8439406 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -5,6 +5,10 @@ analyzer: - lib/generated_plugin_registrant.dart - lib/**/*.g.dart - lib/**/*.freezed.dart + - build/** + - android/** + - ios/** + - macos/** errors: invalid_annotation_target: ignore diff --git a/android/app/build.gradle b/android/app/build.gradle index 30e9e210..5c74667e 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -101,7 +101,7 @@ android { release { signingConfig signingConfigs.release minifyEnabled true - proguardFiles getDefaultProguardFile('proguard-android.txt') + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt') } debug { signingConfig signingConfigs.release diff --git a/android/gradle.properties b/android/gradle.properties index 76272376..ac4727e0 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -2,4 +2,8 @@ org.gradle.jvmargs=-Xmx4g android.useAndroidX=true android.enableJetifier=true android.enableAapt2Daemon=false -kotlin.daemon.jvm.options=-Xmx2g \ No newline at end of file +kotlin.daemon.jvm.options=-Xmx2g +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 8f3239ab..31bf0df1 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-all.zip diff --git a/android/settings.gradle b/android/settings.gradle index 0c0eefa5..8289856e 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -19,12 +19,12 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id 'com.android.application' version '8.13.0' apply false + id 'com.android.application' version '9.4.0' apply false // START: FlutterFire Configuration id "com.google.gms.google-services" version "4.4.3" apply false id "com.google.firebase.crashlytics" version "3.0.6" apply false // END: FlutterFire Configuration - id "org.jetbrains.kotlin.android" version "2.2.20" apply false + id "org.jetbrains.kotlin.android" version "2.4.20" apply false } include ":app" diff --git a/assets/images/SVG/AI.svg b/assets/images/SVG/AI.svg new file mode 100644 index 00000000..2e284855 --- /dev/null +++ b/assets/images/SVG/AI.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/assets/images/SVG/DroidCon.svg b/assets/images/SVG/DroidCon.svg new file mode 100644 index 00000000..e8cc38da --- /dev/null +++ b/assets/images/SVG/DroidCon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/images/SVG/Flutter.svg b/assets/images/SVG/Flutter.svg new file mode 100644 index 00000000..d90a0aff --- /dev/null +++ b/assets/images/SVG/Flutter.svg @@ -0,0 +1,178 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/images/SVG/FlutterCon.svg b/assets/images/SVG/FlutterCon.svg new file mode 100644 index 00000000..2d27bfdd --- /dev/null +++ b/assets/images/SVG/FlutterCon.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/images/SVG/Kotlin.svg b/assets/images/SVG/Kotlin.svg new file mode 100644 index 00000000..5fa1e25f --- /dev/null +++ b/assets/images/SVG/Kotlin.svg @@ -0,0 +1,298 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/images/SVG/React.svg b/assets/images/SVG/React.svg new file mode 100644 index 00000000..0453c211 --- /dev/null +++ b/assets/images/SVG/React.svg @@ -0,0 +1,154 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/images/SVG/Swift.svg b/assets/images/SVG/Swift.svg new file mode 100644 index 00000000..201b8cc6 --- /dev/null +++ b/assets/images/SVG/Swift.svg @@ -0,0 +1,168 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/images/SVG/exhibitions.svg b/assets/images/SVG/exhibitions.svg new file mode 100644 index 00000000..39b76596 --- /dev/null +++ b/assets/images/SVG/exhibitions.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/images/SVG/networking.svg b/assets/images/SVG/networking.svg new file mode 100644 index 00000000..7a2d4813 --- /dev/null +++ b/assets/images/SVG/networking.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/assets/images/SVG/panels.svg b/assets/images/SVG/panels.svg new file mode 100644 index 00000000..8eeff20b --- /dev/null +++ b/assets/images/SVG/panels.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/images/SVG/workshops.svg b/assets/images/SVG/workshops.svg new file mode 100644 index 00000000..58e7e6d5 --- /dev/null +++ b/assets/images/SVG/workshops.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/images/beyond-sessions.png b/assets/images/beyond-sessions.png new file mode 100644 index 00000000..17094f2b Binary files /dev/null and b/assets/images/beyond-sessions.png differ diff --git a/assets/images/dev-days.png b/assets/images/dev-days.png new file mode 100644 index 00000000..bc033738 Binary files /dev/null and b/assets/images/dev-days.png differ diff --git a/assets/images/flutter_icon.png b/assets/images/flutter_icon.png new file mode 100644 index 00000000..c47b3334 Binary files /dev/null and b/assets/images/flutter_icon.png differ diff --git a/assets/images/fluttercon-kenya-26-1920x1080.png b/assets/images/fluttercon-kenya-26-1920x1080.png new file mode 100644 index 00000000..461a47bd Binary files /dev/null and b/assets/images/fluttercon-kenya-26-1920x1080.png differ diff --git a/assets/images/flutterconke_logo.png b/assets/images/flutterconke_logo.png deleted file mode 100644 index d4dfe118..00000000 Binary files a/assets/images/flutterconke_logo.png and /dev/null differ diff --git a/assets/images/flutterconlogo_dark.svg b/assets/images/flutterconlogo_dark.svg new file mode 100644 index 00000000..1ba8acf7 --- /dev/null +++ b/assets/images/flutterconlogo_dark.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/images/flutterconlogo_light.svg b/assets/images/flutterconlogo_light.svg new file mode 100644 index 00000000..288495ea --- /dev/null +++ b/assets/images/flutterconlogo_light.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..86e335cf --- /dev/null +++ b/docs/README.md @@ -0,0 +1,44 @@ +# AI Agent Docs — FlutterCon KE App + +This directory is written **for AI coding agents** (Claude Code, other LLM agents) working in this +repository, not for human onboarding — that's what the root `README.md` and `CLAUDE.md` are for. +Where `CLAUDE.md` gives the short, high-signal orientation, these files go deeper: full inventories, +exact data-flow traces, and file:line references, so an agent can act on a task without re-deriving +the whole codebase from scratch. + +## How to keep this up to date + +These docs describe the state of the code as of the date in each file's "Last verified" line. **Whenever +you (an agent) make a change that invalidates something described here — a new repository, a changed +data-flow pattern, a new feature module, a route added/removed, a CI workflow change — update the +relevant doc in the same PR**, and bump its "Last verified" line. Treat a stale doc as a bug: if you +notice one while working on something else, fix it opportunistically rather than leaving it. + +Do not let this directory grow into a changelog. It describes **current structure and behavior only**. +Historical rationale ("we used to do X before switching to Y") belongs in commit messages and PR +descriptions, not here — unless the historical context is load-bearing for understanding a current +workaround (e.g. why a version is pinned). + +## Index + +| File | Covers | +|---|---| +| [architecture.md](architecture.md) | Layering, DI (`injectable`/`get_it`), state management (Cubits), the app bootstrap sequence | +| [data-layer.md](data-layer.md) | Isar vs Hive, every repository, the cache-first fetch pattern, model/codegen inventory | +| [features.md](features.md) | Per-feature-module breakdown (`lib/features/*`) — what each screen/cubit does | +| [routing.md](routing.md) | go_router route table, navigation entry points, the dashboard's non-router tab system | +| [auth.md](auth.md) | Google sign-in, ghost/anonymous sign-in, token storage, logout, the reviewer-bypass mechanism | +| [networking-and-config.md](networking-and-config.md) | `NetworkUtil`/Dio setup, `ApiRepository` endpoints, flavor config, error/`Failure` handling | +| [notifications.md](notifications.md) | `awesome_notifications` setup, what schedules a notification, the deep-link-back-to-feedback flow | +| [build-and-ci.md](build-and-ci.md) | Flavors, code generation, GitHub Actions workflows and exactly what triggers each one | +| [design/REBRAND-PLAN.md](design/REBRAND-PLAN.md) | The 2026 flutterconKE rebrand migration plan — phases, web-repo cross-references, current gap analysis | + +## Fast orientation for a new task + +1. Read `CLAUDE.md` at the repo root first — it has the handful of facts that change how you should + approach almost any task here (CI targets `main` only, no auth guard on routing, cache-first fetch + semantics, Gradle/AGP/Kotlin version coupling). +2. If the task touches a specific feature screen, go straight to [features.md](features.md) to find + its cubit(s) and files, then [data-layer.md](data-layer.md) if it reads/writes cached data. +3. If the task touches CI or a release, read [build-and-ci.md](build-and-ci.md) first — the workflow + triggers are easy to misread (see the `main`-only note above). diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..0eb80970 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,112 @@ +# Architecture + +*Last verified: 2026-09-11, against `feat/2026_rebrand`.* + +## Layering + +``` +lib/ + main_development.dart / main_staging.dart / main_production.dart # 3 entrypoints, one per flavor + bootstrap.dart # shared startup sequence + MultiBlocProvider wiring + app.dart # MaterialApp.router, theme, notification listener wiring + firebase_options.dart # generated by flutterfire CLI + + core/ + di/injectable.dart # get_it + injectable bootstrap + theme/ # AppTheme, ThemeColors + + common/ + data/ + models/ # freezed remote DTOs (session.dart, speaker.dart, sponsor.dart, feed.dart, ...) + models/local/ # isar_community collections (local_session.dart, local_speaker.dart, ...) + enums/ + repository/ # ApiRepository, AuthRepository, DBRepository, FirebaseRepository, + # HiveRepository, ShareRepository — all @singleton + utils/ # NetworkUtil, FlutterConRouter, NotificationService, flavor_config, etc. + widgets/ # shared widgets (app bar, bottom nav, ...) + + features// + cubit/ # flutter_bloc Cubits + freezed states + ui/ # screens + widgets/ # feature-local widgets + + l10n/ # generated AppLocalizations + arb source + versioning/ # generated build_version.dart (via build_version builder) +``` + +Features present: `about`, `auth`, `dashboard`, `feed`, `feedback`, `home`, `sessions`, `splash`. +`home` contains sponsors/organisers/speakers-related cubits despite the name — it is effectively the +first dashboard tab's data layer, not a single narrow "home screen" concern. Check `lib/features/home/cubit/` +before assuming a piece of data belongs to a differently-named feature. + +## Dependency injection + +`lib/core/di/injectable.dart` + generated `lib/core/di/injectable.config.dart`. `configureDependencies()` +calls `getIt.initGetIt()` then `await getIt.allReady()`, invoked once in `bootstrap.dart` before anything +else runs. + +Every DI-registered class is `@singleton`, constructed with **no arguments** — each one internally +constructs whatever it needs (e.g. `NetworkUtil()` inside `ApiRepository`, `FirebaseAuth.instance` inside +`AuthRepository`). There are currently no `@injectable` (factory-scoped) bindings and no manual bindings +in the empty `@module abstract class RegisterModule {}` placeholder. The full registered set: + +| Type | File | +|---|---| +| `ApiRepository` | `lib/common/repository/api_repository.dart` | +| `AuthRepository` | `lib/common/repository/auth_repository.dart` | +| `DBRepository` | `lib/common/repository/db_repository.dart` | +| `FirebaseRepository` | `lib/common/repository/firebase_repository.dart` | +| `HiveRepository` | `lib/common/repository/hive_repository.dart` | +| `ShareRepository` | `lib/common/repository/share_repository.dart` | +| `NotificationService` | `lib/common/utils/notification_service.dart` | +| `FlutterConRouter` | `lib/common/utils/router.dart` | + +**Cubits are outside the DI graph entirely.** Adding a new Cubit means adding a `BlocProvider` entry to +the `MultiBlocProvider` list in `bootstrap.dart`, pulling its repository dependencies via `getIt()` at +construction time — not annotating the Cubit itself. If a new Cubit needs a repository, register the +repository with `@singleton` (if it isn't already) and pass it via `getIt()` in `bootstrap.dart`. + +## State management + +`flutter_bloc` Cubits (not full Blocs — no repo code uses `Bloc`/events, only `Cubit` + methods). Each +feature's `cubit/` directory holds one Cubit per unit of work (e.g. `FetchSessionsCubit`, +`BookmarkSessionCubit`, `FetchGroupedSessionsCubit` all coexist under `features/sessions` and +`features/home`), each with a freezed sealed state class (typically `Initial` / `Loading` / `Loaded` / +`Error` variants). `AppBlocObserver` (`bootstrap.dart`) logs every Cubit state change and error via +`dart:developer`'s `log()` — check there first when debugging "why didn't the UI update." + +There is no app-wide store beyond what's provided at the root `MultiBlocProvider` in `bootstrap.dart` — +roughly 15 Cubits are constructed and provided there at startup, all living for the app's lifetime (none +are scoped to a route/subtree). + +## Bootstrap sequence + +`bootstrap.dart`'s `bootstrap(builder)` function, called from each `main_.dart` after that +entrypoint sets up `FlutterConConfig` (see [build-and-ci.md](build-and-ci.md) for flavor details): + +1. `Bloc.observer = const AppBlocObserver()` +2. `Firebase.initializeApp(...)` +3. `configureDependencies()` — get_it/injectable wiring +4. `getIt().initBoxes()` +5. `localDB = await getIt().init()` — opens the Isar instance, assigned to the + module-level global `Isar localDB` in `injectable.dart` (not itself part of the DI container) +6. `NotificationService` permission request + init +7. `FirebaseRepository.init()` (remote config) +8. `runApp(MultiBlocProvider(providers: [...], child: await builder()))` + +Errors during steps 1–7 are caught: in debug mode they're just logged; in release mode +`FlutterError.onError` / `PlatformDispatcher.instance.onError` are wired to report to Firebase +Crashlytics instead. A bootstrap failure does not show any fallback UI — if `runApp` is never reached, +the app stays on whatever the platform's default blank/splash surface is. + +## App shell + +`lib/app.dart`'s `MyApp` wraps the tree in `Sizer` (responsive sizing helper) and returns +`MaterialApp.router` with: +- `themeMode`: read once from `HiveRepository.retrieveThemeMode()` at construction time — not reactive; + changing the persisted theme mode elsewhere will not repaint `MaterialApp` without an explicit rebuild + trigger (there is none currently wired for this). +- `theme` / `darkTheme`: `AppTheme.lightTheme()` / `AppTheme.darkTheme()` (`lib/core/theme/theme_data.dart`) +- `routerConfig`: `getIt().config()` +- `AwesomeNotifications().setListeners(onActionReceivedMethod: ...)` wired in `initState` — see + [notifications.md](notifications.md) diff --git a/docs/auth.md b/docs/auth.md new file mode 100644 index 00000000..67f1d303 --- /dev/null +++ b/docs/auth.md @@ -0,0 +1,66 @@ +# Authentication + +*Last verified: 2026-09-11, against `feat/2026_rebrand`.* + +Three ways in, one way out. All auth state (token + profile) lives in Hive, not Isar — see +[data-layer.md](data-layer.md). + +## Google sign-in → app token exchange (two-step) + +This is the normal user-facing path, and it's split across two Cubits deliberately chained in the UI, +not one: + +1. **`GoogleSignInCubit.signInWithGoogle()`** (`lib/features/auth/cubit/google_sign_in_cubit.dart`) calls + `AuthRepository.signInWithGoogle()` (`lib/common/repository/auth_repository.dart:29-73`), which uses + `google_sign_in`'s `GoogleSignIn.instance` to authenticate, then Firebase `signInWithCredential` to + validate. It asserts/throws if the resulting Firebase user is anonymous, and throws if no access + token comes back. **It only returns a raw Google OAuth access token — it does not persist anything.** +2. The UI (`lib/features/auth/ui/sign_in.dart:22-34`) listens for that cubit's `loaded(token)` state and + immediately calls **`SocialAuthSignInCubit.socialSignIn(token: token)`** + (`lib/features/auth/cubit/social_auth_sign_in_cubit.dart`), which POSTs the token to the backend at + `/social_login/google` (`AuthRepository.signIn`) to exchange it for the app's own `AuthResult + {token, user}`, then persists both via `HiveRepository.persistToken()` / `persistUser()` + (`social_auth_sign_in_cubit.dart:20-31`). Only this step's success navigates back to `decisionRoute`. + +If you're debugging "sign-in succeeds but the user isn't actually logged in," check whether the second +step (`SocialAuthSignInCubit`) ran and succeeded — the first step alone persists nothing. + +## Ghost/anonymous sign-in — reviewer bypass, not a general guest mode + +`GhostSignInCubit.signIn()` calls `AuthRepository.ghostSignIn()`, which POSTs a hardcoded shared +credential (`email: 'google@play.com'`, `password: 'password'`) to `/login` — a de facto shared guest +backend account, same persistence path as above via Hive. + +This is **not** exposed as a normal "continue as guest" button. It's gated behind a long-press gesture +on the sign-in screen's logo (`sign_in.dart:69-76`), and the gesture only fires the sign-in call if +*both*: +- `FirebaseRepository.getConfig().isInReview` is true (a Firebase Remote Config flag), **and** +- `config.appVersion == Misc.getAppVersion()` (the remote config's pinned version string matches the + running app's version) + +This exists so app-store reviewers can get into the app without real Google credentials, scoped to a +specific reviewed version — it's intentionally hidden and intentionally narrow. Don't repurpose it as a +general guest-login affordance without understanding this gating is load-bearing for review-bypass +security. + +## Logout + +`LogOutCubit.logOut()` (`lib/features/auth/cubit/log_out_cubit.dart`): +1. `DBRepository.clearAllTables()` — wipes all Isar collections +2. `AuthRepository.logOut()` — `GoogleSignIn.signOut()` +3. `HiveRepository.clearPrefs()` — deletes `accessToken`/`profile`/`sessions` keys + +**Any exception during this sequence is swallowed** — the `catch` block still emits +`LogOutState.loaded()` (`log_out_cubit.dart:32-34`), so from the UI's perspective logout always +"succeeds" even if, say, Isar clearing throws. If a task involves surfacing logout failures, this is +the reason it currently can't. + +## Where the token actually gets used + +`NetworkUtil` (`lib/common/utils/network.dart`) reads `HiveRepository.retrieveToken()` on every request +to set `Authorization: Bearer `. `BookmarkSessionCubit` separately checks +`HiveRepository.retrieveToken()` directly to decide whether bookmarking is allowed at all (see +[data-layer.md](data-layer.md)). There is no token refresh/expiry handling visible in this code — a +401 response is mapped to a `Failure` with message "Session timeout" (see +[networking-and-config.md](networking-and-config.md)) but nothing automatically re-authenticates or +clears the stale token on that response. diff --git a/docs/build-and-ci.md b/docs/build-and-ci.md new file mode 100644 index 00000000..17098e29 --- /dev/null +++ b/docs/build-and-ci.md @@ -0,0 +1,70 @@ +# Build, Flavors & CI + +*Last verified: 2026-09-11, against `feat/2026_rebrand`.* + +## Flavors + +Three entrypoints, each setting up `FlutterConConfig` before calling `bootstrap()` (see +[networking-and-config.md](networking-and-config.md) for exactly what differs between them — mostly +just the Hive box name, not the API target): + +```bash +flutter run --flavor development --target lib/main_development.dart +flutter run --flavor staging --target lib/main_staging.dart +flutter run --flavor production --target lib/main_production.dart +flutter build apk --flavor --target lib/main_.dart +``` + +Android flavor config (app id suffix, `[DEV]`/etc. app name) lives in `android/app/build.gradle`. + +## Local setup requirement: `android/key.properties` + +Not committed (secrets). Required to build locally so social-auth SHA1 matches Firebase's registered +key — create it pointing at the shared public dev keystore: + +```properties +storePassword=publicDevKey@2024 +keyPassword=publicDevKey@2024 +keyAlias=publicDevKey +storeFile=../public-dev-keystore.jks +``` + +(Exact contents/rationale in the root `README.md` "APK Signing" section — check there if this ever +changes, since it's the canonical source, not this file.) + +## Code generation + +`dart run build_runner build --delete-conflicting-outputs` — required after touching any +`@freezed`/`@JsonSerializable`/`@Collection` (isar)/`@HiveType`/`@singleton`/`@injectable`-annotated +class. See [data-layer.md](data-layer.md) for the full generator-to-output mapping. All three CI +workflows run this before `flutter analyze`. + +## Android toolchain version coupling + +`android/gradle/wrapper/gradle-wrapper.properties` (Gradle), `android/settings.gradle` (AGP and Kotlin +Gradle Plugin versions) are tightly coupled — each AGP release has a minimum-supported Gradle version, +and bumping one without checking the other's floor/ceiling is the most common way this project's Android +build breaks. When bumping any of the three, check AGP's release notes for its Gradle minimum first. + +## GitHub Actions workflows (`.github/workflows/`) + +**Important:** `pr-check.yml` and `release-apk.yml` trigger only on `pull_request` events targeting +**`main`**. If PRs are being opened against a different long-lived branch (e.g. a `feat/*` branch under +active development), *neither* runs on those PRs — only the eventual PR from that branch into `main` +gets checked. Always confirm which PR's base branch a CI result actually belongs to before concluding +"CI is broken" or "CI is passing." `release.yml` and `release-internal.yml` are not `pull_request`-triggered +at all (see below), so this caveat doesn't apply to them. + +| Workflow | Trigger | What it does | +|---|---|---| +| `pr-check.yml` ("Check Pull Request") | `pull_request` → `main` | `flutter packages get` → `dart format --set-exit-if-changed lib test` → build_runner → `flutter analyze lib`. Pure lint/format/analyze gate; **`flutter analyze` fails the job on any warning, not just errors** — see [CLAUDE.md](../CLAUDE.md). No build artifact produced. | +| `release-apk.yml` ("Generate Production APK") | `pull_request` → `main` (a `push: branches: [main]` trigger exists in the file but is commented out) | Same format/codegen/analyze steps, then decodes `secrets.PROD_KEYSTORE` into `key.jks`, writes `android/key.properties` from secrets (`PROD_KEYSTORE_PASSPHRASE`/`PROD_KEYSTORE_PASSWORD`/`PROD_KEYSTORE_ALIAS`), builds `flutter build apk --flavor production --target lib/main_production.dart`, uploads `app-production-release.apk` as a workflow artifact. Effectively "build a signed prod APK on every PR for manual testing." | +| `release.yml` ("Generate Production AAB") | `release: types: [published]` (only when a GitHub Release is published) | Same format/codegen/analyze + keystore setup, builds `flutter build appbundle --flavor production`, then sets up Ruby/bundler and runs `bundle exec fastlane supply --aab ... --track production` using `ANDROID_PACKAGE_NAME`/`PLAY_STORE_CREDENTIALS` secrets. **This is the actual Play Store production deploy pipeline** — it only runs when someone publishes a GitHub Release. | +| `release-internal.yml` ("Release Internal Testing AAB") | `workflow_dispatch` (manual, run on demand from the Actions tab) | Identical build/signing steps to `release.yml` (same `production` flavor build, same keystore/secrets), but runs `fastlane supply --track internal` instead of `--track production`. Lets the team push a build to the Play Store **Internal testing** track without cutting a GitHub Release. Bump `version`/build number in `pubspec.yaml` before dispatching — Play Store rejects an upload whose version code was already used on any track. | + +All four only ever build/deploy the **`production`** flavor — there is no CI workflow that builds +`staging` or `development`. All four use `subosito/flutter-action@v2` with `flutter-version: 3` +(latest 3.x, not pinned to an exact version) — CI's Flutter version can drift ahead of what's pinned +for local dev in `android/local.properties`/whatever SDK a contributor has installed; if a build passes +locally but fails in CI (or vice versa) on something codegen-related, check for a Flutter/Dart version +mismatch first. diff --git a/docs/data-layer.md b/docs/data-layer.md new file mode 100644 index 00000000..346aca8d --- /dev/null +++ b/docs/data-layer.md @@ -0,0 +1,126 @@ +# Data Layer + +*Last verified: 2026-09-11, against `feat/2026_rebrand`.* + +## Two stores, two jobs + +| | Isar (`isar_community`) | Hive (`hive_ce`) | +|---|---|---| +| File | `lib/common/repository/db_repository.dart` | `lib/common/repository/hive_repository.dart` | +| Holds | Feed entries, speakers, organisers, individual organisers, sponsors, sessions — the bulk queryable content cache | `accessToken`, `profile` (`FlutterConUser`), `themeMode` — small key-value prefs | +| Why | Supports `.filter()` queries (`titleContains`, `typeEqualTo`, etc.) for search/browsing offline | Simple, fast key-value access; no query needs | +| Opened in | `DBRepository.init()`, called from `bootstrap.dart`, result assigned to the global `Isar localDB` in `lib/core/di/injectable.dart` | `HiveRepository.initBoxes()`, opens one box named `FlutterConConfig.instance!.values.hiveBox` (per-flavor, e.g. `fluttercon-dev`) | +| Wiped on logout | `DBRepository.clearAllTables()` — clears *all* collections | `HiveRepository.clearPrefs()` — deletes only `accessToken`/`profile`/`sessions` keys (there's also an unused `clearBox()` that wipes everything) | + +Both repositories are `@singleton` via injectable; `localDB` itself is a bare global, not fetched +through `getIt`. + +## Isar collections + +Defined in `lib/common/data/models/local/*.dart`, schemas generated into matching `.g.dart` files by +`isar_community_generator`: + +- `LocalFeedEntry` — title, body, topic, url, createdAt, image +- `LocalSpeaker` — name, biography, avatar, tagline, twitter, facebook, linkedin, instagram, blog, companyWebsite +- `LocalOrganiser` — name, logo, tagline, type (`OrganiserType.name` string), bio, designation +- `LocalIndividualOrganiser` — same shape as `LocalOrganiser`, separate collection (searchable independently) +- `LocalSponsor` — name, logo, tagline, link, sponsorType, createdAt +- `LocalSession` — serverId (int, the API's session id — indexed via `serverIdEqualTo`), title, description, + start/end time and datetime, slug, sessionCategory, sessionFormat, sessionLevel, isKeynote, + isBookmarked, isServiceSession, sessionImage, plus embedded `EmbeddedSpeaker` list and `LocalRoom` list + +Notable query behaviors worth knowing before changing this code: +- `fetchOrganisers(type)` explicitly excludes any organiser whose name contains `'Nairobi Gophers'` + (`db_repository.dart:118-124`) — a hardcoded content filter, not a bug; don't "clean this up" without + checking with whoever owns content curation. +- `searchSessions`/`searchSpeakers`/`searchIndividualOrganisers` all do case-insensitive substring + matching across several fields OR'd together — this is what backs `SearchCubit` + (`lib/features/home/cubit/search_cubit.dart`), which touches only Isar, never the network. +- `updateSession` looks up by `serverId`, not Isar's internal auto-increment id — always use the API's + session id when updating a cached session (e.g. toggling a bookmark). +- `hasSessions()` is sync (`countSync()`), used by fetch cubits to decide cache-first vs network-first + without an `await`. + +## Hive keys + +Exactly three keys are ever stored, all in the one per-flavor box: +`accessToken` (String), `profile` (`FlutterConUser`, custom `HiveType` via `FlutterConUserAdapter` in +`lib/common/data/models/adapters.dart`), `themeMode` (String — `ThemeMode.toString()`, parsed back with +`ThemeMode.values.firstWhere(..., orElse: () => ThemeMode.system)`). + +## Remote models + +`lib/common/data/models/*.dart` (excluding `local/`) — freezed classes with `json_serializable` for +`fromJson`/`toJson`: `Auth`/`AuthResult`, `Feed`, `FeedbackDto`, `Meta`, `Organiser`/`Organisers`, +`RemoteConfig`, `Room`, `SearchResult`, `Session`, `Speaker`, `Sponsor`. `Failure` +(`lib/common/data/models/failure.dart`) is hand-written, not freezed — it's the app's one error type, +thrown by `NetworkUtil`/`ApiRepository` and caught by name in cubits (see +[networking-and-config.md](networking-and-config.md)). + +`lib/common/data/models/models.dart` is a barrel file re-exporting the above — prefer importing from it +rather than individual model files, matching existing usage. + +## Repositories (all `@singleton`) + +| Repository | Responsibility | +|---|---| +| `ApiRepository` | Thin wrapper over `NetworkUtil` per endpoint; methods `rethrow` on failure, no local caching logic itself | +| `AuthRepository` | Google sign-in (Firebase `signInWithCredential`), exchanging a Google token for the app's own token (`/social_login/google`), ghost/anonymous sign-in (`/login`), sign-out (`GoogleSignIn.signOut()`) — see [auth.md](auth.md) | +| `DBRepository` | All Isar reads/writes described above | +| `FirebaseRepository` | Firebase Remote Config init/fetch — backs feature flags like `isInReview` (see [auth.md](auth.md)) | +| `HiveRepository` | All Hive reads/writes described above | +| `ShareRepository` | Wraps native share sheet invocation (feed post sharing) | + +## The cache-first fetch pattern + +Every list-fetching cubit for content that's cached in Isar (sessions, speakers, sponsors, organisers, +feed) follows this shape — see e.g. `lib/features/sessions/cubit/fetch_grouped_sessions_cubit.dart`, +`lib/features/home/cubit/fetch_sessions_cubit.dart`, `lib/features/home/cubit/fetch_organisers_cubit.dart`, +`lib/features/feed/cubit/feed_cubit.dart`: + +``` +if (DBRepository has cached rows for this type AND !forceRefresh) { + emit(Loaded(cachedRows)) // instant, from Isar + _networkFetch() // fire-and-forget: ApiRepository -> DBRepository.persist* + // result is NOT re-emitted; UI won't see it until next fetch +} else { + await _networkFetch() // ApiRepository -> DBRepository.persist* + emit(Loaded(await DBRepository.fetch*())) +} +``` + +Practical implications when working on these cubits: +- "The UI shows stale data after the network responds" is expected behavior on the cache-hit path, not + a bug — the background refresh updates Isar for *next* load, it doesn't push to the current screen. + If a task requires live-updating UI on refresh, that's a real behavior change, not a fix. +- A network failure during the background refresh (cache-hit path) can still surface as an error state + even though valid cached data was already emitted, because the same try/catch wraps both the initial + emit and the background call — check the specific cubit before assuming errors always mean "no data." +- `SearchCubit` and any Isar-only path never call `ApiRepository` — don't add network calls there without + changing the documented contract. + +`BookmarkSessionCubit` (`lib/features/sessions/cubit/bookmark_session_cubit.dart`) is the one significant +write path: gates on `HiveRepository.retrieveToken()` (must be signed in), calls +`ApiRepository.bookmarkSession(id)`, updates the cached row via `DBRepository.updateSession(...)`, and on +a new bookmark schedules a local notification via `NotificationService` (see +[notifications.md](notifications.md)). + +## Code generation + +Run via `dart run build_runner build --delete-conflicting-outputs` (required after touching any model, +repository annotation, or DI annotation): + +| Generator | Produces | +|---|---| +| `freezed` | Sealed cubit state classes, remote model classes (`*.freezed.dart`) | +| `json_serializable` | `fromJson`/`toJson` for remote models (`*.g.dart`, same files as freezed for those classes) | +| `isar_community_generator` | Isar collection schemas for `local_*.dart` models (`local_*.g.dart`) | +| `hive_ce_generator` | `TypeAdapter`s for Hive types (`lib/common/data/models/adapters.dart` references generated adapters) | +| `injectable_generator` | `lib/core/di/injectable.config.dart` | +| `build_version` | `lib/versioning/build_version.dart`, configured via `build.yaml` | + +`isar_community`'s version is pinned via a YAML anchor at the top of `pubspec.yaml` +(`isar_version: &isar_version 3.3.2`, reused by `isar_community`, `isar_community_flutter_libs`, +`isar_community_generator`) — keep all three in lockstep if bumping. A prerelease pin here once +transitively capped `analyzer` low enough to crash `build_runner` on newer Dart syntax; if `build_runner` +starts crashing with an analyzer visitor error after a Dart/Flutter SDK bump, check this pin first. diff --git a/docs/design/REBRAND-PLAN.md b/docs/design/REBRAND-PLAN.md new file mode 100644 index 00000000..be1bc80f --- /dev/null +++ b/docs/design/REBRAND-PLAN.md @@ -0,0 +1,298 @@ +# FlutterconKE 2026 Rebrand — Flutter App Migration Plan + +> Status: **All phases (0, 0b, 1, 2, 3, 4, 5) done — migration complete** · Last updated: 2026-10-12 · Tracking: [flutterconKEApp#243](https://github.com/droidconKE/flutterconKEApp/issues/243) + +This plan ports the flutterconKE web rebrand to the Flutter app so both surfaces read as one brand. +It is deliberately modeled on the web repo's own plan +([`flutterconKE2024Web` docs/design/REDESIGN-PLAN.md](https://github.com/droidconKE/flutterconKE2024Web/blob/dev/docs/design/REDESIGN-PLAN.md)) +— same phase shape, adapted for mobile instead of web chrome. Read that doc first if you need the +*design rationale* (color decisions, why things invert between themes, etc.); this doc is about the +Flutter-side execution. + +All work merges into **`feat/2026_rebrand`**, one phase per PR (or more, if a phase is large), each +tracked by its own sub-issue under [#243](https://github.com/droidconKE/flutterconKEApp/issues/243). + +## Source of truth + +- Web redesign issue: [flutterconKE2024Web#23](https://github.com/droidconKE/flutterconKE2024Web/issues/23) +- Web redesign plan: `docs/design/REDESIGN-PLAN.md` on the web repo's `dev` branch +- Web PRs that actually built it (chronological): #24 (plan+assets), #26 (font injection), #27 (design + tokens), #29 (phase 2 homepage), #30 (remove auth), #31 (polish+propagate), #32 (**sessions, speakers, + session detail, about & sponsors redesign** — the most directly portable one, since those are shared + screens with this app), #34–#40 (navbar/footer fixes, revised landing page, about/sponsors pages) +- This app's tracking issue: [flutterconKEApp#243](https://github.com/droidconKE/flutterconKEApp/issues/243) + +## What's already aligned (verified 2026-09-11) + +The Flutter app isn't starting from zero — some of the rebrand already landed incidentally: + +- **Primary/accent colors already match exactly**: `ThemeColors.flutterconBlue` (`#008BFF`) and + `flutterconMagenta` (`#F73EDE`) in `lib/core/theme/theme_colors.dart` are the same hex values as the + web's `primary`/`accent` tokens. +- **The app-bar/sign-in logo asset was already swapped** (`assets/images/flutterconlogo_light.svg` + /`_dark.svg`, commit `741750e`) to the Flutter-blue mark — worth a pixel-diff against the web's + `public/images/new-design/logo-light.svg`/`logo-dark.svg` in Phase 1, but likely just needs + confirmation, not a rebuild. +- **Bottom nav already uses the new accent colors** (`lib/common/widgets/bottom_nav/bottom_nav_bar.dart` + — magenta selected item, blue active icon). + +What's *not* aligned yet — this is the actual scope of the migration: + +- No full color **ramps** (web has `blue-50`..`blue-900`, `magenta-50`..`magenta-900`; the app only has + the two headline hex constants) +- No rounded-card system (web: `rounded-3xl`/`rounded-4xl` = 1.5–2rem; app: default `Card` shapes, small + ad-hoc radii like `Corners.s12Border`) +- No pill-badge language (web's session-level/format chips, `rounded-full` colored pills) +- No stat-card / color-inverting panel pattern (web's About section 2×2 stats block) +- No display typography treatment (web: **Rauschen B**, heavy grotesque, uppercase; app: Montserrat + throughout) — resolved as **won't-port**, see the font note below; Montserrat Black is the permanent + display choice for this app, not a placeholder +- No halftone/duotone image treatment (web's speaker-avatar duotone + dot overlay) +- No pill/rounded button language (web's `.btn-primary`/`.btn-outline`/`.btn-accent`) + +## Font decision — Rauschen B: won't-port (flutterconKEApp#257, decided 2026-09-16) + +Rauschen B lives in the **private** `droidconKE/private-fonts` repo. Its README states the license +terms explicitly: it permits **serving a web font from our own infrastructure**, and explicitly +forbids committing the file to a public repo or sharing it externally. The web sites fetch it at build +time into a gitignored path and serve it via `@font-face`, degrading gracefully to Montserrat when the +fetch has no credentials (e.g. an external contributor) — see that repo's README and the web's +`scripts/fetch-font.sh`. + +That license grant doesn't extend to this app. A compiled mobile app bundles the font file directly +inside every installed APK/IPA — trivially extractable by unzipping the app package — which is a +materially different distribution model than serving a file over HTTPS from a server droidcon +controls. The license text covers the latter; it says nothing about the former, and reads as scoped to +web-serving specifically (it separately calls out that even the `.otf` variant already in that repo +must never be used "for web embedding," i.e. the license is granular about *how* the font may be +served — nothing in it addresses app-binary embedding at all). + +**Decision: do not port this.** Montserrat Black (already shipped in Phase 0's `AppTextStyles.display`) +is the **permanent** display font for this app, not a stand-in pending a future resolution. Revisit only +if whoever holds the Rauschen B license/usage rights explicitly confirms app-binary embedding is +covered — that would need to come from Out of the Dark (the font's creator) or droidcon's brand team, +not be inferred from the web's existing (web-scoped) grant. + +## Phases + +Each phase is its own GitHub sub-issue under #243 and its own branch off `feat/2026_rebrand`. + +### Phase 0 — Design tokens (foundation) ✅ + +Full blue/magenta color ramps as Dart constants, a rounded-card radius scale, pill-badge/button style +constants, uppercase display text style (Montserrat Black — see the font decision below), and the two +documented light/dark color-*inversion* pairs (About-style stat panel: black+magenta-figures in light ↔ +magenta+white-figures in dark) as reusable theme extensions — not deferred to each screen to reinvent. +Foundation only; no screen restyling yet. + +**Shipped** (flutterconKEApp#256): + +- `lib/core/theme/theme_colors.dart` — `AppColorRamps` (blue50–900, magenta50–900) +- `lib/core/theme/theme_styles.dart` — `Corners` extended with the rebrand's rounded-card scale + (s16/s24/s32/s40 + a `pill`/`pillBorder`/`pillRadius` trio) +- `lib/common/widgets/pill_badge.dart` — `PillBadge` widget (`level`/`format` variants) +- `lib/core/theme/button_styles.dart` — `AppButtonStyles.primary`/`.accent`/`.outline` +- `lib/core/theme/text_styles.dart` — `AppTextStyles.display` (Montserrat Black — now the permanent + display choice, see Phase 0b below) +- `lib/core/theme/inverted_panel_theme.dart` — `InvertedPanelColors` `ThemeExtension`, registered on + both `AppTheme.lightTheme()`/`darkTheme()` via `extensions:` + +None of these are wired into any existing screen yet — registering the theme extension has no visual +effect until a screen reads it, and the button/badge/text-style builders are opt-in. Verified via +`flutter analyze` (clean) and a full debug APK build (succeeds). + +### Phase 0b — Rauschen B font decision (parallel, non-blocking) ✅ won't-port + +**Decided 2026-09-16: not porting.** See "Font decision" above for the full reasoning — the license +covers serving a web font from droidcon's own infrastructure, not bundling the file inside a +distributed app binary, and that gap isn't something to resolve by inference. Montserrat Black +(`AppTextStyles.display`, shipped in Phase 0) is the permanent display font for this app. + +### Phase 1 — Global chrome ✅ + +App bar, bottom nav, sign-in screen: confirm/finish logo match, apply Phase 0 tokens to button styles +already present (Google/ghost sign-in buttons), verify active-tab/active-link color usage is using the +new ramps where a single hex is currently hardcoded. + +**Findings** (flutterconKEApp#258): + +- **Logo already matches exactly** — rendered both `assets/images/flutterconlogo_light.svg`/`_dark.svg` + and the web's `public/images/new-design/logo-light.svg`/`logo-dark.svg` to PNG and compared pixel-for- + pixel: identical mark, identical colors, both light and dark variants. No asset change needed. +- **No stray pre-rebrand colors found** — grepped for raw hex `Color(0xff...)` outside the theme files + and for legacy `Colors.blue`/`.amber`/etc.: none. Every color reference already goes through + `ThemeColors`. + - `sign_in.dart`'s only visible button is `GoogleAuthButton` (the `auth_buttons` package) — a + third-party Google-branded button that **should not** be restyled with the rebrand's pill language; + Google's own brand guidelines govern that button's shape. The ghost sign-in path is a hidden + long-press gesture on the logo, not a visible button. So there was no real "apply pill styles to + sign-in buttons" work available on that screen specifically. +- **Applied the pill language where a real opportunity existed**: `FeedbackButton` + (`lib/common/widgets/app_bar/feedback_button.dart`, visible in the app bar on most screens) now uses + `Corners.pillBorder` instead of a fixed 10px radius; the logout confirmation dialog + (`lib/common/widgets/app_bar/logout_dialog.dart`) now uses `Corners.pillBorder` on its destructive + "confirm" button (kept red — destructive-action semantics, not a rebrand color) and + `AppButtonStyles.outline` on its "cancel" button. +- **Deliberately left alone**: `session_filter.dart`'s `SegmentedButton`/filter-panel buttons — that's + session-filtering UI, not global chrome, and belongs with Phase 3's more thorough Sessions pass rather + than a piecemeal touch here. + +### Phase 2 — Home dashboard ✅ + +Home tab restyle: hero-style header treatment, stat-card block (mirrors web's About 2×2 stats, +inverting between light/dark) if an "About"-style summary belongs on Home or the About screen — decide +placement to match how the web's `About.tsx` stats read (edition number, sessions delivered, attendees). + +**Shipped** (flutterconKEApp#259): + +- **Placement: the About screen, not Home.** `about_screen.dart` already had an "About" headline + + description paragraph structurally identical to the web's About section intro — the stat block reads + as a natural continuation of that content, not a dashboard-summary fit for Home. Left Home's "hero- + style header treatment" out of scope here — that's a separate, larger visual question (the web's Hero + is Phase 2 there but a distinct component from the stats block) better handled with its own decision + if/when it comes up, rather than folded into this stat-panel port. +- **`AboutStatsPanel`** (`lib/features/about/widgets/about_stats_panel.dart`) — the 2×2 grid with the + same four values/labels as the web's `About.tsx` (3RD Fluttercon edition, 7th Droidcon edition, 230+ + sessions delivered, 3,000+ attendees since 2018), using Phase 0's `InvertedPanelColors` extension and + `AppTextStyles.display` for the figures. Wired into `about_screen.dart` right after the existing intro + text, before "Organising Team". +- **Verified visually**, not just via analyze/build — ran the app on an emulator, navigated to the About + screen, and confirmed both color-inversion states directly: black panel + magenta figures in light + mode, magenta panel + white figures in dark mode (toggled via `adb shell cmd uimode night yes/no`), + matching the web's design exactly. + +### Phase 3 — Sessions & Speakers ✅ + +The highest-value phase — directly portable from web PR #32. Session cards (`schedule_view_card.dart`, +`compact_view_card.dart`, `day_sessions_view.dart`) get the rounded-card treatment, time-badge box, and +level/format pill badges matching `SessionListCard.tsx`'s pattern exactly (same badge colors: magenta +pill for level, blue pill for format). Speaker cards/grid tiles get the duotone/halftone avatar +treatment from `SpeakerCard.tsx` (grayscale + contrast + blue duotone + dot overlay — achievable with +`ColorFiltered` + a custom dot-pattern `Painter`/asset overlay in Flutter). + +**Shipped** (flutterconKEApp#260): + +- **`SessionTimeBadge`** (`lib/features/sessions/ui/widgets/session_time_badge.dart`) — the rounded + time-badge box (time + AM/PM stacked), reused by both session card variants. +- **`CompactViewCard`** rebuilt from a `ListTile` to a custom layout matching `SessionListCard.tsx`: + rounded-4xl bordered card, `SessionTimeBadge`, level/format `PillBadge`s (skipped for service + sessions, matching the web's `!is_serviceSession` check), speaker names in primary color. +- **`ScheduleViewCard`**: same rounded-4xl border + `SessionTimeBadge` + pill badges added alongside its + existing image banner and speaker-avatar row (kept — the web has no direct equivalent of this + image-forward variant, so it keeps its own richer layout rather than being forced into + `SessionListCard.tsx`'s shape). +- **`SpeakerDuotoneAvatar`** (`lib/features/home/widgets/speaker_duotone_avatar.dart`) — grayscale + + contrast `ColorFiltered`, then a second `ColorFiltered` with `BlendMode.screen` against blue (the + Flutter equivalent of the web's `mix-blend-screen` over a `bg-blue-600` backdrop), plus a + `CustomPainter` dot overlay. Wired into `SpeakerGridTile` (speakers grid/list) and + `SpeakerDetailsPage` (detail header) — **not** into the shared `PersonnelWidget`, since that widget is + also used for organisers on the About screen, which don't get this speaker-specific treatment on the + web either. +- `SpeakerGridTile` also picked up the rounded-4xl bordered card shape to match session cards. + +**Verification note**: the session-card redesign (compact + schedule views, both light and dark mode) +was confirmed visually on an emulator — rounded borders, time badges, and pill badges all render as +intended. The speaker duotone avatar was verified by code review and a clean `flutter analyze`/build +only, not a fresh on-device screenshot — the emulator's touch-input injection stopped registering taps +partway through this phase's verification (swipes still worked; likely an emulator-session quirk, not +an app issue) and recovery attempts (adb restart, alternate input sources) didn't resolve it. Worth a +manual look on a real device/fresh emulator before considering this phase fully visually confirmed. + +### Phase 4 — Sponsors, Feed, Feedback, About ✅ + +Apply Phase 0 tokens/components consistently across the remaining screens. Sponsors tiering visuals, +feed post cards, feedback emoji/rating UI, about/organiser cards all get the rounded-card + pill/button +language so nothing looks pre-rebrand next to Sessions/Speakers. + +**Shipped** (flutterconKEApp#261): + +- **`SponsorsCard`/`OrganizersCard`** (`lib/features/home/widgets/`): outer card radius bumped to + `Corners.s24Border`, inner logo tiles to `Corners.s16Border`, and the border changed from + dark-mode-only (`transparent` in light, `colorScheme.primary` in dark) to always-on — matching every + other rebrand card, which borders in both themes rather than treating it as a dark-mode-only accent. + Added missing item spacing to `SponsorsCard`'s horizontal tile list (it previously relied on the + light-mode border being invisible to hide the lack of a gap; with an always-on border that would have + made adjacent tiles look fused). +- **`feed_screen.dart`**: each post wrapped in a `Corners.s32Border`-bordered card (replacing a bare + divider-separated list), feed images switched from a `Container` with a `borderRadius` that was + never actually clipping its child (`BoxDecoration.borderRadius` doesn't clip content — a pre-existing + no-op, not something this phase introduced) to a proper `ClipRRect` with `Corners.s16Border`. +- **`feedback_screen.dart`**: submit button now uses Phase 0's `AppButtonStyles.primary(context)` + instead of a hand-rolled `ElevatedButton.styleFrom`. Also fixed its label color, which was set to + `colorScheme.surface` — near-black in dark mode, i.e. dark text on the blue button background, a + pre-existing low-contrast bug — to a plain white matching the button style's foreground. +- **`EmojiContainer`** (feedback rating tiles): radius bumped to `Corners.s16Border` to match the rest + of the card scale. +- **About/organiser cards**: no change needed beyond what Phase 3 already decided — + `organising_team.dart`'s grid uses the shared `PersonnelWidget` (deliberately not given the + speaker-only duotone treatment, see Phase 3), and `organising_team_details.dart` has no card chrome + to restyle, matching `speaker_details.dart`'s equally chrome-less layout. + +**Verification**: `SponsorsCard`/`OrganizersCard` confirmed visually on an emulator in both light and +dark mode — rounded borders, always-on border color, and tile spacing all render as intended. The feed +card restyle could **not** be visually confirmed — the test event's feed endpoint returned no data +("Not found") on the build used for this pass, unrelated to this phase's changes. The feedback button +swap was verified by code review and clean analyze/build only, not re-screenshotted, since it reuses +the already-proven `AppButtonStyles.primary`. + +### Phase 5 — Polish ✅ + +Dark-mode QA against both documented inversion cases, accessibility pass (contrast on the new ramps), +responsive check across phone sizes, remove any now-unused pre-rebrand color constants/assets, and +update this doc + the AI-agent `docs/` (see [docs/README.md](../README.md)) to reflect the final state. + +**Shipped** (flutterconKEApp#262): + +- **Accessibility pass — contrast computed for every new color pairing** (WCAG relative-luminance + formula, not eyeballed): + - **Fixed**: `PillBadge`'s format variant and `SessionTimeBadge`'s light-mode text were + `flutterconBlue` (blue700) on `blue50` — 3.2:1, below the 4.5:1 AA threshold for this text size. + Both are internal component choices (not literal copies of a specific shipped web class value for + *this* exact pairing), so swapped to `blue800` from the same ramp — 5.1:1, passes AA, and reads as + visually identical at a glance. + - **Found, not changed — inherited from the shared design system, shipped identically on web**: + - `AppButtonStyles.primary` (white text on `flutterconBlue`): 3.4:1. This is a direct, intentional + copy of the web's `.btn-primary` (`text-white bg-primary`) — already live on the public site with + the same ratio. Changing it only in the Flutter app would make the two surfaces diverge, which is + the opposite of this migration's goal. + - `InvertedPanelColors.dark` (white figures on the magenta panel): 3.1:1. This is the explicit, + documented color-inversion design decision from the web (see "What's already aligned" above) — + also not something to override unilaterally. + - The magenta bottom-nav-selected-item color on a white light-mode background: 3.1:1. Pre-existing, + predates this rebrand entirely (same `flutterconMagenta` value used for selected state before + Phase 0 started). + + All three are AA-compliant for *large* text (≥3:1) but not small/normal text at 4.5:1. Worth a + conversation with whoever owns the Figma export if the team wants the brand ramp itself adjusted — + that would fix web and app together, which a Flutter-only patch can't. +- **Dark-mode QA**: re-confirmed visually across every phase's deliverable this session covered — + session cards (Phase 3), sponsors/organisers cards (Phase 4) — matching their light-mode counterparts + correctly, and the one documented inversion case (About stats panel, Phase 2) swaps correctly in both + directions. +- **Responsive check**: reasoned through `AboutStatsPanel`'s fixed `childAspectRatio` grid (the + component most likely to be fragile on a narrow phone, since everything else in this rebrand uses + `Wrap`/`Expanded`/`AutoSizeText` which reflow safely) against a 320dp-wide device budget — content + height clears the available cell height with margin to spare even with the longest label ("ATTENDEES + SINCE 2018") wrapping to two lines. Not re-verified on an actual small-width emulator this round. +- **Cleanup**: removed `ThemeColors.greyDarkThemeBackground` (zero usages anywhere) and + `assets/images/flutterconke_logo.png` (the pre-rebrand logo bitmap, unreferenced by any Dart code or + config since the Flutter-blue SVG mark replaced it — confirmed by checking every asset-referencing + config file, not just `lib/`). Left the unrelated pre-existing unused `Corners` scale entries + (`s3`/`s5`/`s8`/`dialog`/`btn` and their `Border`/`Radius` variants) alone — they predate this rebrand + entirely and aren't "now-unused because of" anything this migration did. +- **Docs**: checked `docs/architecture.md` and `docs/data-layer.md` (the AI-agent docs that mention + theming) against the current code — both still accurate, no updates needed. + +This closes out the 2026 flutterconKE rebrand migration (#243) — all six phases (0, 0b, 1, 2, 3, 4, 5) +shipped. + +## Notes for whoever picks up a phase + +- The web repo is the layout/pattern reference, not a literal 1:1 spec — mobile navigation, card density, + and touch targets differ from a desktop/responsive web layout. Match the *visual language* (colors, + radii, pill badges, duotone treatment, typography weight/case), not literal pixel measurements. +- Cross-check hex values against `tailwind.config.js` on the web repo's `dev` branch if this doc ever + goes stale — that file is the source of truth for the ramps, this doc just mirrors it at time of + writing. +- Keep phases small enough to review; the web side did the same (many small PRs into one integration + branch, squashed into `dev` once approved) rather than one giant redesign PR. diff --git a/docs/features.md b/docs/features.md new file mode 100644 index 00000000..1a9497ae --- /dev/null +++ b/docs/features.md @@ -0,0 +1,80 @@ +# Features + +*Last verified: 2026-09-11, against `feat/2026_rebrand`. File lists below exclude generated `.g.dart`/`.freezed.dart`.* + +Each feature module under `lib/features//` typically has `cubit/` (Cubit + freezed state), +`ui/` (screens), and sometimes `widgets/` (feature-local widgets). None of these Cubits are DI-registered +— they're constructed in `lib/bootstrap.dart`'s `MultiBlocProvider` (see [architecture.md](architecture.md)). + +## `splash` + +- `ui/splash_screen.dart` — the `/` route. Currently navigates straight to the dashboard route on the + first frame; does not check auth state (see [routing.md](routing.md) for why). + +## `dashboard` + +- `ui/dashboard_screen.dart` — hosts the 4-tab bottom nav (Home, Feed, Sessions, About) via its own + `PageController`/`PageView`, **not** a go_router `ShellRoute`. Tab switching doesn't touch the route + stack. + +## `home` + +Despite the name, this module owns most of the dashboard's first-tab *data* fetching, not just the +home screen UI: + +- `cubit/fetch_sessions_cubit.dart`, `fetch_organisers_cubit.dart`, `fetch_speakers_cubit.dart`, + `fetch_sponsors_cubit.dart` — each follows the cache-first pattern from + [data-layer.md](data-layer.md) for its respective content type. +- `cubit/home_cubits.dart` — barrel file re-exporting the above cubits/states. +- `cubit/search_cubit.dart` — Isar-only search across sessions/speakers/individual organisers (see + `DBRepository.searchSessions`/`searchSpeakers`/`searchIndividualOrganisers`); never touches the network. +- `ui/home_screen.dart` — the Home tab: sponsor/session/speaker preview cards. +- `ui/speakers_list_screen.dart`, `ui/speaker_details/speaker_details.dart` — full speaker list and + detail page (reached via `/dashboard/speakers` and `/speaker-details`, see [routing.md](routing.md)). +- `widgets/` — `organizers_card.dart`, `search_bar.dart`, `sessions_card.dart`, `speaker_grid_tile.dart`, + `speaker_home_card.dart`, `sponsors_card.dart` — presentational cards used on the home screen. + +## `sessions` + +- `cubit/fetch_grouped_sessions_cubit.dart` — sessions grouped (by day/time) for the schedule view, + cache-first pattern. +- `cubit/bookmark_session_cubit.dart` — the write path: toggles a session's bookmark via + `ApiRepository` + `DBRepository.updateSession`, gated on being signed in + (`HiveRepository.retrieveToken()`), and schedules a feedback-reminder notification on new bookmarks + (see [notifications.md](notifications.md)). +- `ui/sessions_screen.dart` — the Sessions tab, with day-tab and schedule/compact view toggles + (`ui/widgets/day_tab_view.dart`, `day_sessions_view.dart`, `schedule_view_card.dart`, + `compact_view_card.dart`). +- `ui/session_details/session_details.dart` — detail page, reached via `/session-details` with a + `LocalSession` passed as `state.extra`. + +## `feed` + +- `cubit/feed_cubit.dart` — cache-first fetch of feed entries (announcements/posts). +- `cubit/share_feed_post_cubit.dart` — wraps `ShareRepository` to share a feed post through the native + share sheet. +- `ui/feed_screen.dart`, `widgets/share_sheet.dart`, `widgets/social_media_button.dart`. + +## `feedback` + +- `cubit/send_feedback_cubit.dart` — submits session feedback via `ApiRepository` + (`FeedbackDto`/`feedback_dto.dart`). +- `ui/feedback_screen.dart` — reached via `/feedback`, optionally pre-filled with a `sessionSlug` passed + as `state.extra`; this is also the deep-link target from the bookmark-reminder notification (see + [notifications.md](notifications.md)). +- `widgets/` — `back_button.dart`, `emoji_container.dart` (feedback rating UI), + `feedback_custom_appbar.dart`. + +## `about` + +- `cubit/fetch_individual_organisers_cubit.dart` — cache-first fetch of individual organiser profiles + (as opposed to `home`'s organiser-team-level fetch — check `OrganiserType` usage before assuming + these are the same data). +- `ui/about_screen.dart`, `ui/organising_team.dart`, `ui/organising_team_details.dart` — reached via + `/organiser-details` for the detail page, with a `LocalIndividualOrganiser` passed as `state.extra`. + +## `auth` + +See [auth.md](auth.md) for the full flow. Cubits: `google_sign_in_cubit.dart`, +`social_auth_sign_in_cubit.dart`, `ghost_sign_in_cubit.dart`, `log_out_cubit.dart`. UI: `ui/sign_in.dart` +(also hosts the hidden reviewer-bypass long-press gesture, see [auth.md](auth.md)). diff --git a/docs/networking-and-config.md b/docs/networking-and-config.md new file mode 100644 index 00000000..398f2587 --- /dev/null +++ b/docs/networking-and-config.md @@ -0,0 +1,92 @@ +# Networking & Configuration + +*Last verified: 2026-09-11, against `feat/2026_rebrand`.* + +## Flavor configuration + +`FlutterConConfig` (`lib/common/utils/env/flavor_config.dart`) is a manual singleton (factory +constructor returning a cached `_instance`) holding a `FlutterConValues` record set once by whichever +`main_.dart` runs first. All three flavors currently point at the **same** API domain: + +| Flavor | `baseDomain` | `hiveBox` | `eventSlug` | `organiserSlug` | +|---|---|---|---|---| +| development | `api.droidcon.co.ke` | `fluttercon-dev` | `flutterconke-2025-171` | `flutterconke-571` | +| staging | `api.droidcon.co.ke` | `fluttercon-stg` | `flutterconke-2025-171` | `flutterconke-571` | +| production | `api.droidcon.co.ke` | `fluttercon-prod` | `flutterconke-2025-171` | `flutterconke-571` | + +In other words, **there is no separate staging/dev backend today** — flavors only isolate the local Hive +box (so signing in as dev doesn't clobber prod's cached token) and the app id suffix +(`android/app/build.gradle`), not the API target. Don't assume `development`/`staging` are safe to hit +destructively differently from `production` — they hit the real API. + +`eventSlug`/`organiserSlug` are baked into API paths (`/events/$eventSlug/...`) — this app is +effectively hardwired to one droidcon/FlutterCon event per build; supporting multiple events would need +these to become runtime-configurable rather than compile-time constants. + +## `NetworkUtil` (`lib/common/utils/network.dart`) + +Manually-implemented singleton (factory constructor pattern, not `@singleton`/injectable) wrapping +`dio`. `_getHttpClient()` builds a **fresh `Dio` instance per request** with: +- `baseUrl`: `'${FlutterConConfig.instance!.values.baseUrl}/v1'` +- Static header `Api-Authorization-Key: droidconKe-2020` +- Request interceptor injecting `Authorization: Bearer ` from + `getIt().retrieveToken()` (empty string if not signed in) +- `PrettyDioLogger` only in `kDebugMode` +- `badCertificateCallback` that **always returns `true`** — accepts any TLS certificate, including + invalid/expired/self-signed ones. This is a real security-relevant detail, not something to casually + "fix" as a drive-by in an unrelated change — but do flag it if a task's scope includes network security. +- `connectTimeout`/`receiveTimeout` are both set to `Duration(seconds: 60 * 1000)` — that's **60,000 + seconds (~16.7 hours)**, not 60 seconds. This looks like a units mistake (probably meant + `Duration(milliseconds: 60 * 1000)`), meaning requests effectively never time out client-side on a + hung connection. Note there's also an unused `ApiConstants` class + (`lib/common/utils/constants/api_constants.dart`) with a correct 15-second timeout and generic + endpoint constants (`/users`, `/posts`, etc.) that don't match any real endpoint here — it appears to + be leftover boilerplate from a template and is not wired into `NetworkUtil`. + +Four request methods, all with the same try/catch shape: `getReq`, `postReq`, `putReq`, +`postWithFormData` (multipart, used for endpoints needing file upload alongside form fields). Error +mapping is consistent across all four: + +| Condition | Result | +|---|---| +| Response body is an empty map | `Failure('An error occured, please try again later')` | +| `SocketException` | `Failure('No internet connection')` | +| `TimeoutException` | `Failure('Session timeout')` | +| `DioException`, status 401 | `Failure('Session timeout', statusCode: 401)` | +| `DioException`, status 404 | `Failure('Not found', statusCode: 404)` | +| `DioException`, status 422 (POST/PUT/form only) | `Failure(response.data['message'], statusCode: 422)` | +| `DioException`, status 500 | `Failure(response.data['message'], statusCode: 500)` | +| `DioException`, type `unknown` | bare `Exception('Server error')` (not a `Failure`) | +| `DioException`, type `connectionTimeout`/`connectionError` | rethrown as `SocketException` | +| anything else | bare `Exception('Server error')` | + +Because some paths throw `Failure` and others throw a bare `Exception`/`SocketException`, callers that +only `catch (e) on Failure` and fall back to a generic message for everything else (the pattern used +throughout the cubits) will show a generic error for those non-`Failure` cases — this is intentional +enough to be the existing contract, but worth knowing when writing a new cubit: catch `Failure` +specifically for a precise message, and a general `catch` for everything else, matching existing cubits. + +There is no 401-triggered auto-logout or token clear anywhere — a expired/invalid token just keeps +producing "Session timeout" failures until the user manually logs out and back in. + +## `ApiRepository` endpoints (`lib/common/repository/api_repository.dart`) + +Each method is a thin wrapper: build the path (using `_eventSlug`/`_organiserSlug` where relevant), call +`NetworkUtil`, parse the typed response, `rethrow` on any error (no local error transformation at this +layer — that all happens in `NetworkUtil`). + +| Method | Endpoint | +|---|---| +| `fetchSpeakers({perPage, page})` | `GET /events/$eventSlug/speakers` | +| `fetchRooms()` | `GET /events/$eventSlug/rooms` | +| `fetchSessions({perPage, page})` | `GET /events/$eventSlug/sessions` (check file for exact path if adding pagination logic) | +| `fetchFeeds({perPage, page})` | `GET /events/$eventSlug/...` feed endpoint | +| `fetchOrganisers(...)` | organiser-team endpoint, parameterized by organiser type | +| `fetchSponsors({perPage, page})` | `GET /events/$eventSlug/sponsors` | +| `bookmarkSession(sessionId)` | write endpoint toggling a session bookmark | +| `submitFeedback({feedbackDTO})` | feedback submission endpoint | + +(Full request bodies/exact paths are in the file itself — this table is for locating the right method +quickly, not a substitute for reading it before changing request shapes.) + +The full REST API is also documented externally via Postman — link in the root `README.md`. diff --git a/docs/notifications.md b/docs/notifications.md new file mode 100644 index 00000000..9d0a1c40 --- /dev/null +++ b/docs/notifications.md @@ -0,0 +1,40 @@ +# Notifications + +*Last verified: 2026-09-11, against `feat/2026_rebrand`. Source: `lib/common/utils/notification_service.dart`.* + +Uses **`awesome_notifications`**, not `flutter_local_notifications` — the latter is still a +`pubspec.yaml` dependency but appears unused for this purpose; check for other usages before assuming +it's load-bearing anywhere. + +## Setup + +`NotificationService` (`@singleton`) is initialized in `bootstrap.dart`: `requestPermission()` (asks +for OS permission if not already granted) then `initNotifications()`, which registers exactly one +channel, `session_channel` (high importance, sound + vibration + the app's blue as its color). If a new +notification *type* is needed, it needs its own channel added here — the whole notification system is +built around this single channel today. + +## What schedules a notification + +The only trigger in the codebase is bookmarking a session. `BookmarkSessionCubit.bookmarkSession()` +(`lib/features/sessions/cubit/bookmark_session_cubit.dart`) calls +`NotificationService.createScheduledNotification(session: ..., channelKey: 'session_channel')` when a +session transitions to bookmarked (not on un-bookmark). + +`createScheduledNotification` (`notification_service.dart:51-82`) schedules for +**`session.endDateTime - 5 minutes`** (i.e. fires near the end of the talk, not at bookmark time), with +title `" feedback"` and body prompting the user to submit feedback, `payload: +{'sessionSlug': session.slug}`. The notification's `id` is the session's local Isar id (`session.id`), +so re-bookmarking the same session re-schedules under the same id (overwrites rather than duplicates). +There's a commented-out line for testing with a 5-second delay instead — leave it commented in shipped +code, uncomment only for local manual testing. + +## Deep link on tap + +`app.dart`'s `MyApp.initState()` wires `AwesomeNotifications().setListeners(onActionReceivedMethod: +NotificationService.onActionReceivedMethod)`. That static callback +(`notification_service.dart:84-97`, annotated `@pragma('vm:entry-point')` so it survives tree-shaking +and works when the app is launched cold from a notification tap) switches on `channelKey` — currently +only handles `'session_channel'` — and pushes `FlutterConRouter.feedbackRoute` with the payload's +`sessionSlug` as `extra`, landing the user on `FeedbackScreen` pre-scoped to that session. Adding a +second notification type means adding both a new channel (above) and a new `case` here. diff --git a/docs/routing.md b/docs/routing.md new file mode 100644 index 00000000..d270b0f3 --- /dev/null +++ b/docs/routing.md @@ -0,0 +1,57 @@ +# Routing + +*Last verified: 2026-09-11, against `feat/2026_rebrand`. Source: `lib/common/utils/router.dart`.* + +`FlutterConRouter` is a `@singleton` wrapping a single `static final GoRouter _router`, exposed to the +app via `config()` and consumed in `lib/app.dart` as `MaterialApp.router(routerConfig: ...)`. + +## Route table + +| Path | Name constant | Screen | Data passed | +|---|---|---|---| +| `/` | `decisionRoute` | `SplashScreen` | — | +| `/sign-in` | `signInRoute` | `SignInScreen` | — | +| `/dashboard` | `dashboardRoute` | `DashboardScreen` | — | +| `/dashboard/speakers` | `speakerListRoute` (child of dashboard) | `SpeakerListScreen` | — | +| `/session-details` | `sessionDetailsRoute` | `SessionDetailsPage` | `state.extra` as `LocalSession` (required, force-unwrapped) | +| `/speaker-details` | `speakerDetailsRoute` | `SpeakerDetailsPage` | `state.extra` as `LocalSpeaker` (required) | +| `/organiser-details` | `organiserDetailsRoute` | `OranisingTeamMemberDetailsPage` | `state.extra` as `LocalIndividualOrganiser` (required) | +| `/feedback` | `feedbackRoute` | `FeedbackScreen` | `state.extra` as `String?` (optional `sessionSlug`) | + +Because `session-details`/`speaker-details`/`organiser-details` force-unwrap `state.extra`, navigating +to them via a raw URL (e.g. a deep link, or `context.go('/session-details')` without `extra:`) will throw. +These routes are only ever meant to be reached via in-app `context.go(route, extra: theObject)` calls +that already hold the object — they are not designed to reconstruct state from a URL/id. + +## No auth guard + +There is no `redirect:` callback anywhere in the `GoRouter` config. `SplashScreen.initState()` +(`lib/features/splash/splash_screen.dart:20-26`) unconditionally calls +`GoRouter.of(context).goNamed(FlutterConRouter.dashboardRoute)` after the first frame — its own comment +says "Go directly to the landing page." It does not check `HiveRepository.retrieveToken()` or any auth +state. Practical effect: **unauthenticated users land straight on the dashboard**; sign-in is only +reached by explicitly navigating to `signInRoute` from somewhere in the UI (e.g. a profile/settings +affordance), not enforced by the router. If a task says "add an auth check to routing," this is the +file and the exact behavior to change — there's nothing partially built to extend, it's a clean +insertion point. + +## Dashboard tabs are not routes + +`DashboardScreen` (`lib/features/dashboard/ui/dashboard_screen.dart`) is a plain `StatefulWidget` with +its own `PageController`/`PageView` and a `CustomBottomNavigationBar` +(`lib/common/widgets/bottom_nav/bottom_nav_bar.dart`) cycling through 4 `PageItem`s: Home, Feed, +Sessions, About. Switching tabs calls in-widget methods (`switchTab`/`jumpToPage`) and never touches +`GoRouter` — the URL/route stack doesn't reflect which tab is active, and there is no deep link into a +specific tab. If a task needs "link directly to the Sessions tab," that requires adding state (e.g. a +query param or a new nested route) — it doesn't exist today. + +## `globalNavigatorKey` is not actually stable + +`FlutterConRouter.globalNavigatorKey` is a `static GlobalKey get` — because it's a +getter with `GlobalKey()` in its body rather than a cached `static final` field, **every +read returns a brand new key**. The router itself only uses the value captured once at its own static +initialization (`navigatorKey: globalNavigatorKey` inside the `_router` field initializer, evaluated +once), so routing itself works fine — but any *other* code that reads `FlutterConRouter.globalNavigatorKey` +expecting to get the router's actual navigator key back will get a different, useless key each time. +Don't use this getter to reach the app's navigator from elsewhere; if that's needed, it should be +refactored to a cached field first. diff --git a/lib/common/data/models/local/local_session.dart b/lib/common/data/models/local/local_session.dart index 8af7a418..08d5ec98 100644 --- a/lib/common/data/models/local/local_session.dart +++ b/lib/common/data/models/local/local_session.dart @@ -75,6 +75,6 @@ class EmbeddedSpeaker { class LocalRoom { LocalRoom({this.title, this.id}); - int? id; + String? id; String? title; } diff --git a/lib/common/data/models/room.dart b/lib/common/data/models/room.dart index 4b29e48a..9b54e4c2 100644 --- a/lib/common/data/models/room.dart +++ b/lib/common/data/models/room.dart @@ -5,7 +5,7 @@ part 'room.g.dart'; @freezed abstract class Room with _$Room { - factory Room({required int id, required String title}) = _Room; + factory Room({required String id, required String title}) = _Room; factory Room.fromJson(Map json) => _$RoomFromJson(json); } diff --git a/lib/common/repository/auth_repository.dart b/lib/common/repository/auth_repository.dart index 1bc9aea5..c774c533 100644 --- a/lib/common/repository/auth_repository.dart +++ b/lib/common/repository/auth_repository.dart @@ -57,7 +57,11 @@ class AuthRepository { if (user.isAnonymous) { throw Failure(message: 'User must not be anonymous'); } - return Future.value(authResult.credential?.accessToken); + final accessToken = authResult.credential?.accessToken; + if (accessToken == null) { + throw Failure(message: 'Missing access token'); + } + return accessToken; } else { throw Failure(message: 'An unexpected error occured'); } diff --git a/lib/common/utils/constants/app_assets.dart b/lib/common/utils/constants/app_assets.dart index 8741ed2e..7e9893b4 100644 --- a/lib/common/utils/constants/app_assets.dart +++ b/lib/common/utils/constants/app_assets.dart @@ -49,8 +49,10 @@ class AppAssets { static const aboutIcon = 'assets/images/about_icon.png'; static const lockIcon = 'assets/images/lock_icon.png'; static const playIcon = 'assets/images/play_icon.png'; - static const flutterConKeLogo = 'assets/images/flutterconke_logo.png'; - static const flutterConBanner = 'assets/images/flutterconke_banner.png'; + static const flutterConLogoLight = 'assets/images/flutterconlogo_light.svg'; + static const flutterConLogoDark = 'assets/images/flutterconlogo_dark.svg'; + static const flutterConBanner = + 'assets/images/fluttercon-kenya-26-1920x1080.png'; static const smileyIcon = 'assets/images/smiley.png'; static const profilePhoto = 'assets/images/profile_photo.png'; @@ -69,4 +71,22 @@ class AppAssets { static const viewAgendaIcon = 'assets/images/view_agenda.svg'; static const filterIcon = 'assets/images/filter.svg'; static const backIcon = 'assets/images/back.svg'; + + // Conf. Highlights + static const beyondSessions = 'assets/images/beyond-sessions.png'; + static const devDays = 'assets/images/dev-days.png'; + + // Conf. Essentials + static const assetsSvg = 'assets/images/SVG'; + static const svgPanels = '$assetsSvg/panels.svg'; + static const svgWorkshops = '$assetsSvg/workshops.svg'; + static const svgNetworking = '$assetsSvg/networking.svg'; + static const svgExhibitions = '$assetsSvg/exhibitions.svg'; + static const svgFlutter = '$assetsSvg/Flutter.svg'; + static const svgKotlin = '$assetsSvg/Kotlin.svg'; + static const svgSwift = '$assetsSvg/Swift.svg'; + static const svgReact = '$assetsSvg/React.svg'; + static const svgAi = '$assetsSvg/AI.svg'; + static const svgDroidCon = '$assetsSvg/DroidCon.svg'; + static const svgFlutterCon = '$assetsSvg/FlutterCon.svg'; } diff --git a/lib/common/utils/notification_service.dart b/lib/common/utils/notification_service.dart index 33770577..d0961e41 100644 --- a/lib/common/utils/notification_service.dart +++ b/lib/common/utils/notification_service.dart @@ -15,7 +15,7 @@ class NotificationService { channelKey: 'session_channel', channelName: 'Session notifications', channelDescription: 'Notification channel for bookmarked sessions', - defaultColor: ThemeColors.blueDroidconColor, + defaultColor: ThemeColors.flutterconBlue, ledColor: Colors.white, playSound: true, enableVibration: true, diff --git a/lib/common/widgets/app_bar/app_bar.dart b/lib/common/widgets/app_bar/app_bar.dart index 4943884f..09448e48 100644 --- a/lib/common/widgets/app_bar/app_bar.dart +++ b/lib/common/widgets/app_bar/app_bar.dart @@ -1,157 +1,44 @@ -import 'package:auto_size_text/auto_size_text.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; import 'package:fluttercon/common/utils/constants/app_assets.dart'; import 'package:fluttercon/common/utils/misc.dart'; import 'package:fluttercon/common/widgets/app_bar/feedback_button.dart'; -import 'package:fluttercon/common/widgets/app_bar/session_filter.dart'; import 'package:fluttercon/common/widgets/app_bar/user_profile_icon.dart'; -import 'package:fluttercon/core/theme/theme_colors.dart'; -import 'package:fluttercon/l10n/l10n.dart'; -import 'package:wolt_modal_sheet/wolt_modal_sheet.dart'; +/// The shared app bar across all dashboard tabs: logo, feedback pill, and +/// profile avatar. The Sessions tab's view-toggle/filter controls live in +/// their own toolbar below this (`SessionViewToolbar`) rather than crowding +/// in here — see docs/design/REBRAND-PLAN.md. class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { - const CustomAppBar({ - required this.selectedIndex, - super.key, - this.onCompactTapped, - this.onScheduleTapped, - this.selectedScheduleIndex = 0, - }); + const CustomAppBar({required this.selectedIndex, super.key}); + final int selectedIndex; - final void Function()? onCompactTapped; - final void Function()? onScheduleTapped; - final int? selectedScheduleIndex; @override Size get preferredSize => const Size.fromHeight(kToolbarHeight); @override Widget build(BuildContext context) { - final (isLightMode, colorScheme) = Misc.getTheme(context); - final l10n = context.l10n; + final (isLightMode, _) = Misc.getTheme(context); return AppBar( automaticallyImplyLeading: false, - surfaceTintColor: colorScheme.surface, - backgroundColor: colorScheme.surface, + surfaceTintColor: Theme.of(context).scaffoldBackgroundColor, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, title: Row( children: [ GestureDetector( onTap: () {}, - child: Image.asset( + child: SvgPicture.asset( isLightMode - ? AppAssets.flutterConKeLogo - : AppAssets.flutterConKeLogo, - scale: 4.5, + ? AppAssets.flutterConLogoLight + : AppAssets.flutterConLogoDark, + height: 24, ), ), const Spacer(), - if (selectedIndex != 2) FeedbackButton(selectedIndex: selectedIndex), - if (selectedIndex != 2) const SizedBox(width: 15), - if (selectedIndex != 2) const UserProfileIcon(), - if (selectedIndex == 2) - Row( - children: [ - GestureDetector( - onTap: () => onCompactTapped?.call(), - child: SvgPicture.asset( - AppAssets.listAltIcon, - colorFilter: ColorFilter.mode( - selectedScheduleIndex == 0 - ? ThemeColors.blueColor - : Colors.grey, - BlendMode.srcIn, - ), - ), - ), - const SizedBox(width: 24), - GestureDetector( - onTap: () => onScheduleTapped?.call(), - child: SvgPicture.asset( - AppAssets.viewAgendaIcon, - colorFilter: ColorFilter.mode( - selectedScheduleIndex == 1 - ? ThemeColors.blueColor - : Colors.grey, - BlendMode.srcIn, - ), - ), - ), - GestureDetector( - onTap: () { - WoltModalSheet.show( - context: context, - barrierDismissible: true, - pageListBuilder: (context) => [ - WoltModalSheetPage( - backgroundColor: colorScheme.secondaryContainer, - leadingNavBarWidget: Padding( - padding: const EdgeInsets.only(left: 16), - child: Row( - children: [ - SvgPicture.asset( - AppAssets.filterIcon, - colorFilter: ColorFilter.mode( - colorScheme.primary, - BlendMode.srcIn, - ), - ), - const SizedBox(width: 8), - AutoSizeText( - l10n.filter, - style: Theme.of(context).textTheme.titleLarge - ?.copyWith( - fontWeight: FontWeight.bold, - color: colorScheme.primary, - ), - ), - ], - ), - ), - trailingNavBarWidget: GestureDetector( - onTap: () { - Navigator.of(context).pop(); - }, - child: Padding( - padding: const EdgeInsets.only(right: 16), - child: AutoSizeText( - l10n.cancel.toUpperCase(), - style: const TextStyle( - color: Colors.grey, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - child: const SessionFilter(), - ), - ], - modalTypeBuilder: (_) => WoltModalType.dialog(), - ); - }, - child: Row( - children: [ - const SizedBox(width: 32), - AutoSizeText( - l10n.filter, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - color: Colors.grey, - ), - ), - const SizedBox(width: 8), - SvgPicture.asset( - AppAssets.filterIcon, - colorFilter: const ColorFilter.mode( - Colors.grey, - BlendMode.srcIn, - ), - ), - ], - ), - ), - ], - ), + FeedbackButton(selectedIndex: selectedIndex), + const SizedBox(width: 15), + const UserProfileIcon(), ], ), ); diff --git a/lib/common/widgets/app_bar/feedback_button.dart b/lib/common/widgets/app_bar/feedback_button.dart index 1a2c162d..5b2f1ff2 100644 --- a/lib/common/widgets/app_bar/feedback_button.dart +++ b/lib/common/widgets/app_bar/feedback_button.dart @@ -6,6 +6,7 @@ import 'package:fluttercon/common/utils/router.dart'; import 'package:fluttercon/common/widgets/bottom_nav/app_nav_icon.dart'; import 'package:fluttercon/core/di/injectable.dart'; import 'package:fluttercon/core/theme/theme_colors.dart'; +import 'package:fluttercon/core/theme/theme_styles.dart'; import 'package:fluttercon/l10n/l10n.dart'; import 'package:go_router/go_router.dart'; @@ -23,7 +24,7 @@ class FeedbackButton extends StatelessWidget { onTap: () { final profile = getIt().retrieveUser(); if (profile == null) { - GoRouter.of(context).goNamed(FlutterConRouter.signInRoute); + GoRouter.of(context).pushNamed(FlutterConRouter.signInRoute); return; } @@ -33,8 +34,8 @@ class FeedbackButton extends StatelessWidget { height: 30, width: 127, decoration: BoxDecoration( - color: ThemeColors.tealColor.withValues(alpha: 0.21), - borderRadius: const BorderRadius.all(Radius.circular(10)), + color: ThemeColors.flutterconMagenta.withValues(alpha: 0.21), + borderRadius: Corners.pillBorder, ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, @@ -54,7 +55,7 @@ class FeedbackButton extends StatelessWidget { const AppNavIcon( 'send', height: 12, - color: ThemeColors.tealColor, + color: ThemeColors.flutterconMagenta, ), ], ), diff --git a/lib/common/widgets/app_bar/logout_dialog.dart b/lib/common/widgets/app_bar/logout_dialog.dart index 5a477ccb..659bc47c 100644 --- a/lib/common/widgets/app_bar/logout_dialog.dart +++ b/lib/common/widgets/app_bar/logout_dialog.dart @@ -3,6 +3,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:fluttercon/common/utils/misc.dart'; import 'package:fluttercon/common/utils/router.dart'; +import 'package:fluttercon/core/theme/button_styles.dart'; +import 'package:fluttercon/core/theme/theme_styles.dart'; import 'package:fluttercon/features/auth/cubit/log_out_cubit.dart'; import 'package:fluttercon/l10n/l10n.dart'; import 'package:go_router/go_router.dart'; @@ -63,7 +65,7 @@ class _LogOutDialogState extends State { backgroundColor: Colors.red, padding: const EdgeInsets.symmetric(horizontal: 32), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + borderRadius: Corners.pillBorder, ), ), child: BlocBuilder( @@ -93,16 +95,9 @@ class _LogOutDialogState extends State { const SizedBox(height: 8), SizedBox( width: double.infinity, - child: ElevatedButton( + child: OutlinedButton( onPressed: () => GoRouter.of(context).pop(), - style: ElevatedButton.styleFrom( - elevation: 0, - backgroundColor: colorScheme.surface, - padding: const EdgeInsets.symmetric(horizontal: 32), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), + style: AppButtonStyles.outline(context), child: AutoSizeText( l10n.cancel.toUpperCase(), style: Theme.of(context).textTheme.titleMedium?.copyWith( diff --git a/lib/common/widgets/app_bar/user_profile_icon.dart b/lib/common/widgets/app_bar/user_profile_icon.dart index 696eba23..4bcc0e1e 100644 --- a/lib/common/widgets/app_bar/user_profile_icon.dart +++ b/lib/common/widgets/app_bar/user_profile_icon.dart @@ -29,7 +29,7 @@ class _UserProfileIconState extends State { onTap: () { final profile = getIt().retrieveUser(); if (profile == null) { - GoRouter.of(context).goNamed(FlutterConRouter.signInRoute); + GoRouter.of(context).pushNamed(FlutterConRouter.signInRoute); return; } @@ -66,7 +66,7 @@ class _UserProfileIconState extends State { alignment: Alignment.center, decoration: const BoxDecoration( shape: BoxShape.circle, - color: ThemeColors.tealColor, + color: ThemeColors.flutterconMagenta, ), child: ValueListenableBuilder( valueListenable: Hive.box( diff --git a/lib/common/widgets/bottom_nav/bottom_nav_bar.dart b/lib/common/widgets/bottom_nav/bottom_nav_bar.dart index 749e1287..18315ff6 100644 --- a/lib/common/widgets/bottom_nav/bottom_nav_bar.dart +++ b/lib/common/widgets/bottom_nav/bottom_nav_bar.dart @@ -91,9 +91,9 @@ class _CustomBottomNavigationBarState extends State { : null, child: BottomNavigationBar( type: BottomNavigationBarType.fixed, - backgroundColor: colorScheme.surface, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, currentIndex: widget.selectedIndex, - selectedItemColor: ThemeColors.orangeDroidconColor, + selectedItemColor: ThemeColors.flutterconMagenta, unselectedItemColor: colorScheme.onSurface, unselectedLabelStyle: const TextStyle(fontSize: 12), selectedLabelStyle: const TextStyle(fontSize: 12), diff --git a/lib/common/widgets/dashed_divider.dart b/lib/common/widgets/dashed_divider.dart new file mode 100644 index 00000000..e1c52fc6 --- /dev/null +++ b/lib/common/widgets/dashed_divider.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +/// Thin dotted rule used between sponsor/organiser tiers on the web's +/// `SponsorsList.tsx` (`border-t border-dotted`) — Flutter has no built-in +/// dashed `Divider`, so this draws one with `CustomPaint`. +class DashedDivider extends StatelessWidget { + const DashedDivider({required this.color, super.key}); + + final Color color; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 1, + width: double.infinity, + child: CustomPaint(painter: _DashedLinePainter(color: color)), + ); + } +} + +class _DashedLinePainter extends CustomPainter { + _DashedLinePainter({required this.color}); + + final Color color; + static const _dashWidth = 4.0; + static const _dashGap = 4.0; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..strokeWidth = 1; + var x = 0.0; + while (x < size.width) { + canvas.drawLine(Offset(x, 0), Offset(x + _dashWidth, 0), paint); + x += _dashWidth + _dashGap; + } + } + + @override + bool shouldRepaint(covariant _DashedLinePainter oldDelegate) => + oldDelegate.color != color; +} diff --git a/lib/common/widgets/personnel_widget.dart b/lib/common/widgets/personnel_widget.dart index c3684e6e..b80bcfa3 100644 --- a/lib/common/widgets/personnel_widget.dart +++ b/lib/common/widgets/personnel_widget.dart @@ -1,9 +1,9 @@ import 'package:auto_size_text/auto_size_text.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:fluttercon/common/utils/misc.dart'; import 'package:fluttercon/core/theme/theme_colors.dart'; import 'package:fluttercon/core/theme/theme_styles.dart'; +import 'package:fluttercon/features/home/widgets/speaker_duotone_avatar.dart'; class PersonnelWidget extends StatelessWidget { const PersonnelWidget({ @@ -11,6 +11,7 @@ class PersonnelWidget extends StatelessWidget { required this.name, this.designation, this.onTap, + this.maxWidth, super.key, }); @@ -19,53 +20,59 @@ class PersonnelWidget extends StatelessWidget { final String? designation; final VoidCallback? onTap; + /// Overrides the default screen-width-based sizing (used by callers that + /// lay this out in a fixed-width grid cell, e.g. the organising team + /// grid). Left null for callers like the horizontal speaker list, which + /// gives this widget unbounded width and so needs the screen-based + /// fallback. + final double? maxWidth; + @override Widget build(BuildContext context) { - final (isLightMode, colorScheme) = Misc.getTheme(context); + final (isLightMode, _) = Misc.getTheme(context); + final width = maxWidth ?? MediaQuery.sizeOf(context).width / 4.5; return GestureDetector( onTap: onTap, child: Column( children: [ Container( - constraints: BoxConstraints( - maxWidth: MediaQuery.sizeOf(context).width / 4.5, - ), + constraints: BoxConstraints(maxWidth: width), decoration: BoxDecoration( - border: Border.all(color: ThemeColors.tealColor, width: 2), + border: Border.all( + color: ThemeColors.flutterconMagenta, + width: 2, + ), borderRadius: Corners.s12Border, ), - child: ClipRRect( - borderRadius: Corners.s10Border, - child: CachedNetworkImage( - fit: BoxFit.cover, - imageUrl: imageUrl, - placeholder: (_, __) => const SizedBox( - height: 100, - width: double.infinity, - child: Center(child: CircularProgressIndicator()), - ), - errorWidget: (_, __, ___) => const SizedBox( - height: 100, - width: double.infinity, - child: Icon(Icons.error, color: Colors.red), - ), + child: AspectRatio( + aspectRatio: 1, + child: ClipRRect( + borderRadius: Corners.s10Border, + child: SpeakerDuotoneAvatar(imageUrl: imageUrl), ), ), ), + const SizedBox(height: 8), SizedBox( - width: MediaQuery.sizeOf(context).width / 4.5, + width: width, child: AutoSizeText( name, maxLines: 1, - style: TextStyle(fontSize: 12, color: colorScheme.onSurface), + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: ThemeColors.flutterconMagenta, + ), ), ), if (designation != null) SizedBox( - width: MediaQuery.sizeOf(context).width / 4.5, + width: width, child: AutoSizeText( designation!, - maxLines: 1, + maxLines: 2, + textAlign: TextAlign.center, style: TextStyle( fontSize: 12, color: isLightMode diff --git a/lib/common/widgets/pill_badge.dart b/lib/common/widgets/pill_badge.dart new file mode 100644 index 00000000..df273d86 --- /dev/null +++ b/lib/common/widgets/pill_badge.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:fluttercon/common/utils/misc.dart'; +import 'package:fluttercon/core/theme/theme_colors.dart'; +import 'package:fluttercon/core/theme/theme_styles.dart'; + +/// Which pill variant to render. Matches the web's `levelPill`/`formatPill` +/// classes in `components/sessions/SessionListCard.tsx` on the +/// flutterconKE2024Web repo — see docs/design/REBRAND-PLAN.md. Session level +/// (e.g. "Beginner", "Keynote") is magenta; session format (e.g. "Talk", +/// "Workshop") is blue. +enum PillBadgeVariant { level, format } + +/// A small rounded-full label chip introduced by the 2026 rebrand's +/// pill-badge language. Used on session cards (Phase 3, flutterconKEApp#260) +/// for level/format badges. +class PillBadge extends StatelessWidget { + const PillBadge({required this.label, required this.variant, super.key}); + + final String label; + final PillBadgeVariant variant; + + @override + Widget build(BuildContext context) { + final (isLightMode, _) = Misc.getTheme(context); + + final Color background; + final Color foreground; + switch (variant) { + case PillBadgeVariant.level: + background = isLightMode + ? AppColorRamps.magenta100 + : AppColorRamps.magenta500.withValues(alpha: 0.15); + foreground = isLightMode + ? AppColorRamps.magenta800 + : ThemeColors.flutterconMagenta; + case PillBadgeVariant.format: + background = isLightMode + ? AppColorRamps.blue50 + : ThemeColors.flutterconBlue.withValues(alpha: 0.2); + // blue700 (flutterconBlue) on blue50 is only 3.2:1 — below WCAG AA + // for this text size. blue800 keeps the same brand-blue family + // while clearing 4.5:1. + foreground = isLightMode + ? AppColorRamps.blue800 + : AppColorRamps.blue300; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: background, + borderRadius: Corners.pillBorder, + ), + child: Text( + label, + style: TextStyle( + color: foreground, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} diff --git a/lib/common/widgets/resolved_image.dart b/lib/common/widgets/resolved_image.dart index a59d8991..2e57eaa1 100644 --- a/lib/common/widgets/resolved_image.dart +++ b/lib/common/widgets/resolved_image.dart @@ -4,20 +4,29 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:logger/logger.dart'; class ResolvedImage extends StatelessWidget { - const ResolvedImage({required this.imageUrl, super.key}); + const ResolvedImage({ + required this.imageUrl, + this.height, + this.width, + super.key, + }); final String imageUrl; + final double? height; + final double? width; @override Widget build(BuildContext context) { final size = MediaQuery.sizeOf(context); Logger().d('ResolvedImage: $imageUrl'); + final resolvedHeight = height ?? size.height * .15; + final resolvedWidth = width ?? size.width * .3; return imageUrl.contains('.svg') - ? SvgPicture.network(imageUrl) + ? SvgPicture.network(imageUrl, height: height, width: width) : CachedNetworkImage( imageUrl: imageUrl, - height: size.height * .15, - width: size.width * .3, + height: resolvedHeight, + width: resolvedWidth, placeholder: (_, __) => const SizedBox( height: 150, width: double.infinity, diff --git a/lib/common/widgets/status_view.dart b/lib/common/widgets/status_view.dart new file mode 100644 index 00000000..91542348 --- /dev/null +++ b/lib/common/widgets/status_view.dart @@ -0,0 +1,84 @@ +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:flutter/material.dart'; +import 'package:fluttercon/common/utils/misc.dart'; +import 'package:fluttercon/core/theme/button_styles.dart'; +import 'package:fluttercon/core/theme/theme_colors.dart'; +import 'package:fluttercon/l10n/l10n.dart'; + +/// A centered icon + title + message state, for empty states ("no posts +/// yet") and error states ("couldn't load the feed") alike. Originally +/// built for the Feed screen, which used to render the raw API error +/// string (e.g. literally "Not found") as bare top-left text with no +/// context or way to retry; promoted to `common/widgets` once Sessions +/// needed the identical pattern — its `BlocBuilder` had no `error` case at +/// all, so a failed fetch just spun the loading indicator forever instead +/// of showing anything. +class StatusView extends StatelessWidget { + const StatusView({ + required this.icon, + required this.title, + required this.message, + super.key, + this.onRetry, + }); + + final IconData icon; + final String title; + final String message; + final VoidCallback? onRetry; + + @override + Widget build(BuildContext context) { + final (isLightMode, colorScheme) = Misc.getTheme(context); + + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isLightMode + ? AppColorRamps.blue50 + : ThemeColors.flutterconBlue.withValues(alpha: 0.15), + ), + child: Icon(icon, size: 48, color: colorScheme.primary), + ), + const SizedBox(height: 24), + AutoSizeText( + title, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + const SizedBox(height: 8), + AutoSizeText( + message, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: isLightMode + ? ThemeColors.greyTextColor + : ThemeColors.lightGreyTextColor, + ), + ), + if (onRetry != null) ...[ + const SizedBox(height: 24), + ElevatedButton( + style: AppButtonStyles.primary(context), + onPressed: onRetry, + child: Text(context.l10n.tryAgain.toUpperCase()), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/core/di/injectable.dart b/lib/core/di/injectable.dart index e2c46a79..c768c3ce 100644 --- a/lib/core/di/injectable.dart +++ b/lib/core/di/injectable.dart @@ -6,7 +6,7 @@ import 'package:isar_community/isar.dart'; final getIt = GetIt.instance; late Isar localDB; -@InjectableInit(initializerName: 'initGetIt', generateForDir: ['lib']) +@InjectableInit(initializerName: 'initGetIt') Future configureDependencies() async { getIt.initGetIt(); await getIt.allReady(); diff --git a/lib/core/theme/button_styles.dart b/lib/core/theme/button_styles.dart new file mode 100644 index 00000000..1cde1922 --- /dev/null +++ b/lib/core/theme/button_styles.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:fluttercon/common/utils/misc.dart'; +import 'package:fluttercon/core/theme/theme_colors.dart'; +import 'package:fluttercon/core/theme/theme_styles.dart'; + +/// Pill-shaped button styles introduced by the 2026 rebrand, mirroring the +/// web's `.btn-primary` / `.btn-outline` / `.btn-accent` utility classes in +/// styles/globals.css on the flutterconKE2024Web repo — see +/// docs/design/REBRAND-PLAN.md. +/// +/// These are opt-in builders, not wired into `ThemeData`'s +/// `elevatedButtonTheme`/`outlinedButtonTheme` defaults — doing so would +/// restyle every existing button app-wide as a side effect of this +/// (foundation-only) phase. Screens adopt these explicitly as they're +/// migrated (Phase 1 onward). +/// +/// The web variants render their label text uppercase via a CSS class; +/// Flutter's `TextStyle` has no text-transform equivalent, so callers must +/// uppercase the label string themselves (e.g. `label.toUpperCase()`) +/// rather than relying on these styles to do it. +class AppButtonStyles { + AppButtonStyles._(); + + static const _labelStyle = TextStyle( + fontWeight: FontWeight.w500, + letterSpacing: 0.5, + ); + + static const _padding = EdgeInsets.symmetric(horizontal: 32, vertical: 14); + + /// Solid brand-blue pill button. Matches `.btn-primary`. + static ButtonStyle primary(BuildContext context) { + return ElevatedButton.styleFrom( + backgroundColor: ThemeColors.flutterconBlue, + foregroundColor: Colors.white, + textStyle: _labelStyle, + padding: _padding, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: Corners.pillBorder), + ); + } + + /// Solid brand-magenta pill button, black label. Matches `.btn-accent`. + static ButtonStyle accent(BuildContext context) { + return ElevatedButton.styleFrom( + backgroundColor: ThemeColors.flutterconMagenta, + foregroundColor: Colors.black, + textStyle: _labelStyle, + padding: _padding, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: Corners.pillBorder), + ); + } + + /// Transparent pill button with a hairline border. Matches `.btn-outline`. + static ButtonStyle outline(BuildContext context) { + final (_, colorScheme) = Misc.getTheme(context); + + return OutlinedButton.styleFrom( + foregroundColor: colorScheme.onSurface, + textStyle: _labelStyle, + padding: _padding, + side: BorderSide(color: colorScheme.onSurface.withValues(alpha: 0.3)), + shape: RoundedRectangleBorder(borderRadius: Corners.pillBorder), + ); + } +} diff --git a/lib/core/theme/inverted_panel_theme.dart b/lib/core/theme/inverted_panel_theme.dart new file mode 100644 index 00000000..86edf7e5 --- /dev/null +++ b/lib/core/theme/inverted_panel_theme.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:fluttercon/core/theme/theme_colors.dart'; + +/// Colors for a panel whose scheme *inverts* between light and dark mode — +/// a deliberate design choice from the 2026 rebrand, not a dark-mode bug. +/// Two documented instances on the web (see docs/design/REBRAND-PLAN.md): +/// the About stat panel (black background + magenta figures in light mode, +/// magenta background + white figures in dark mode) and the Developer Days +/// card (same pattern). Register one instance per `ThemeData.extensions` so +/// screens read `Theme.of(context).extension()!` +/// instead of hardcoding the swap themselves. +@immutable +class InvertedPanelColors extends ThemeExtension { + const InvertedPanelColors({required this.background, required this.figure}); + + /// Light mode: black background, magenta figures. + static const InvertedPanelColors light = InvertedPanelColors( + background: ThemeColors.blackColor, + figure: ThemeColors.flutterconMagenta, + ); + + /// Dark mode: magenta background, white figures. + static const InvertedPanelColors dark = InvertedPanelColors( + background: ThemeColors.flutterconMagenta, + figure: Colors.white, + ); + + final Color background; + final Color figure; + + @override + InvertedPanelColors copyWith({Color? background, Color? figure}) { + return InvertedPanelColors( + background: background ?? this.background, + figure: figure ?? this.figure, + ); + } + + @override + InvertedPanelColors lerp( + ThemeExtension? other, + double t, + ) { + if (other is! InvertedPanelColors) return this; + return InvertedPanelColors( + background: Color.lerp(background, other.background, t) ?? background, + figure: Color.lerp(figure, other.figure, t) ?? figure, + ); + } +} diff --git a/lib/core/theme/text_styles.dart b/lib/core/theme/text_styles.dart new file mode 100644 index 00000000..acc3fab5 --- /dev/null +++ b/lib/core/theme/text_styles.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +/// Display typography introduced by the 2026 rebrand. The web uses a +/// licensed heavy-grotesque display font (Rauschen B), fetched at build +/// time from a private repo and served from droidcon's own web +/// infrastructure — the license explicitly covers that use. +/// +/// This app deliberately does **not** do the same: the license grants +/// serving a web font from our own infrastructure, but a compiled mobile +/// app instead bundles the font file inside every installed APK/IPA — +/// trivially extractable by unzipping — which is a materially different +/// distribution than serving over HTTPS from a server we control, and +/// isn't something the license text actually covers. See +/// flutterconKEApp#257 and docs/design/REBRAND-PLAN.md for the full +/// reasoning. [display] uses Montserrat Black as the permanent choice, not +/// a placeholder pending a future decision — the same font family already +/// used for this app's body text, so the display style reads as a weight/ +/// case variation of the existing type rather than a mismatched one-off. +class AppTextStyles { + AppTextStyles._(); + + /// Large uppercase display headline (e.g. a hero/section title). Caller + /// is responsible for uppercasing the label string — this only sets the + /// weight/spacing, matching the web's `.title`/`font-display` treatment. + static TextStyle display({double fontSize = 32, Color? color}) { + return GoogleFonts.montserrat( + fontSize: fontSize, + fontWeight: FontWeight.w900, + letterSpacing: 0.5, + height: 1, + color: color, + ); + } +} diff --git a/lib/core/theme/theme_colors.dart b/lib/core/theme/theme_colors.dart index 644a0177..efc9105d 100644 --- a/lib/core/theme/theme_colors.dart +++ b/lib/core/theme/theme_colors.dart @@ -4,17 +4,46 @@ class ThemeColors { ThemeColors._(); static const Color lightGrayBackgroundColor = Color(0xffF5F5F5); - static const Color blueDroidconColor = Color(0xff000CEB); - static const Color orangeDroidconColor = Color(0xffFF6E4D); - static const Color greyDarkThemeBackground = Color(0xff20201E); static const Color greyTextColor = Color(0xff707070); static const Color lightGreyTextColor = Color(0xffC3C3C3); - static const Color blueGreenDroidconColor = Color(0xff00E2C3); - static const Color blueColor = Color(0xff000CEB); - static const Color orangeColor = Color(0xffFF6E4D); - static const Color blackColor = Color(0xff20201E); - static const Color tealColor = Color(0xff00E2C3); + static const Color blackColor = Color(0xff000000); static const Color lightGrayColor = Color(0xffF5F5F5); static const Color greyAccentColor = Color(0xffB1B1B1); + + static const Color flutterconBlue = Color(0xff008BFF); + static const Color flutterconMagenta = Color(0xffF73EDE); +} + +/// Full blue/magenta color ramps from the flutterconKE 2026 rebrand's Figma +/// variable export. Values must stay identical to `blue`/`magenta` in +/// `tailwind.config.js` on the flutterconKE2024Web repo's `dev` branch — see +/// docs/design/REBRAND-PLAN.md. [ThemeColors.flutterconBlue] is [blue700]; +/// [ThemeColors.flutterconMagenta] is [magenta500] — kept as separate named +/// constants above since they're the two values used as `primary`/`accent` +/// throughout the existing theme. +class AppColorRamps { + AppColorRamps._(); + + static const Color blue50 = Color(0xffEDFAFF); + static const Color blue100 = Color(0xffD6F3FF); + static const Color blue200 = Color(0xffB5EBFF); + static const Color blue300 = Color(0xff83E1FF); + static const Color blue400 = Color(0xff48CEFF); + static const Color blue500 = Color(0xff1EB3FF); + static const Color blue600 = Color(0xff069AFF); + static const Color blue700 = Color(0xff008BFF); + static const Color blue800 = Color(0xff086AC5); + static const Color blue900 = Color(0xff0D5A9B); + + static const Color magenta50 = Color(0xffFFF3FE); + static const Color magenta100 = Color(0xffFFE7FE); + static const Color magenta200 = Color(0xffFFCEFC); + static const Color magenta300 = Color(0xffFFA7F5); + static const Color magenta400 = Color(0xffFF57E9); + static const Color magenta500 = Color(0xffF73EDE); + static const Color magenta600 = Color(0xffDB1EBE); + static const Color magenta700 = Color(0xffB6159A); + static const Color magenta800 = Color(0xff95137D); + static const Color magenta900 = Color(0xff791664); } diff --git a/lib/core/theme/theme_data.dart b/lib/core/theme/theme_data.dart index d84397e5..8b346ce2 100644 --- a/lib/core/theme/theme_data.dart +++ b/lib/core/theme/theme_data.dart @@ -1,5 +1,7 @@ +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:fluttercon/core/theme/inverted_panel_theme.dart'; import 'package:fluttercon/core/theme/theme_colors.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -23,24 +25,24 @@ class AppTheme { scaffoldBackgroundColor: Colors.white, colorScheme: const ColorScheme( brightness: Brightness.light, - primary: ThemeColors.blueDroidconColor, - primaryContainer: ThemeColors.blueDroidconColor, + primary: ThemeColors.flutterconBlue, + primaryContainer: ThemeColors.flutterconBlue, onPrimary: Colors.white, - secondary: ThemeColors.blueGreenDroidconColor, + secondary: ThemeColors.flutterconMagenta, secondaryContainer: ThemeColors.lightGrayColor, - onSecondary: Colors.black, + onSecondary: Colors.white, surface: Colors.white, - onSurface: Colors.black, + onSurface: Color(0xff20201E), error: Colors.red, onError: Colors.white, ), appBarTheme: const AppBarTheme( backgroundColor: Colors.white, - foregroundColor: ThemeColors.blueDroidconColor, + foregroundColor: ThemeColors.flutterconBlue, ), navigationBarTheme: const NavigationBarThemeData( backgroundColor: Colors.white, - indicatorColor: ThemeColors.blueDroidconColor, + indicatorColor: ThemeColors.flutterconBlue, elevation: 3, ), textTheme: GoogleFonts.montserratTextTheme(), @@ -50,32 +52,33 @@ class AppTheme { TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(), }, ), + extensions: const [InvertedPanelColors.light], ); } static ThemeData darkTheme() { return ThemeData( - scaffoldBackgroundColor: ThemeColors.greyDarkThemeBackground, + scaffoldBackgroundColor: ThemeColors.blackColor, colorScheme: const ColorScheme( brightness: Brightness.dark, - primary: ThemeColors.blueGreenDroidconColor, - primaryContainer: ThemeColors.blueDroidconColor, - onPrimary: Colors.black, - secondary: ThemeColors.blueDroidconColor, - secondaryContainer: Colors.black, + primary: ThemeColors.flutterconBlue, + primaryContainer: ThemeColors.flutterconBlue, + onPrimary: Colors.white, + secondary: ThemeColors.flutterconMagenta, + secondaryContainer: Color(0xff191D1D), onSecondary: Colors.white, - surface: ThemeColors.blackColor, + surface: Color(0xff20201E), onSurface: Colors.white, error: Colors.red, onError: Colors.white, ), appBarTheme: const AppBarTheme( - backgroundColor: ThemeColors.greyDarkThemeBackground, - foregroundColor: ThemeColors.blueDroidconColor, + backgroundColor: ThemeColors.blackColor, + foregroundColor: ThemeColors.flutterconBlue, ), navigationBarTheme: const NavigationBarThemeData( - backgroundColor: Colors.black, - indicatorColor: ThemeColors.blueDroidconColor, + backgroundColor: ThemeColors.blackColor, + indicatorColor: ThemeColors.flutterconBlue, elevation: 3, ), textTheme: GoogleFonts.montserratTextTheme(), @@ -85,6 +88,7 @@ class AppTheme { TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(), }, ), + extensions: const [InvertedPanelColors.dark], ); } } diff --git a/lib/core/theme/theme_styles.dart b/lib/core/theme/theme_styles.dart index b177c8ce..ad7a27c6 100644 --- a/lib/core/theme/theme_styles.dart +++ b/lib/core/theme/theme_styles.dart @@ -116,4 +116,51 @@ class Corners { static BorderRadius get s12Border => BorderRadius.all(s12Radius); static Radius get s12Radius => const Radius.circular(s12); + + // --- 2026 rebrand rounded-card scale --- + // Mirrors the web's `borderRadius` scale in tailwind.config.js + // (rounded-2xl/3xl are Tailwind defaults, 4xl/5xl are the rebrand's own + // additions there). Used for the rounded-card system introduced by the + // rebrand — see docs/design/REBRAND-PLAN.md. Distinct from the s3–s12 + // scale above, which predates the rebrand and is still used by + // pre-rebrand widgets. + + /// Rounded-2xl — small rounded elements (e.g. a time-badge box). + static const double s16 = 16; + + static BorderRadius get s16Border => BorderRadius.all(s16Radius); + + static Radius get s16Radius => const Radius.circular(s16); + + /// Rounded-3xl — e.g. the About stat-panel block. + static const double s24 = 24; + + static BorderRadius get s24Border => BorderRadius.all(s24Radius); + + static Radius get s24Radius => const Radius.circular(s24); + + /// Rounded-4xl — the rebrand's primary card radius (session cards, + /// speaker cards). + static const double s32 = 32; + + static BorderRadius get s32Border => BorderRadius.all(s32Radius); + + static Radius get s32Radius => const Radius.circular(s32); + + /// Rounded-5xl. + static const double s40 = 40; + + static BorderRadius get s40Border => BorderRadius.all(s40Radius); + + static Radius get s40Radius => const Radius.circular(s40); + + /// Rounded-full — pill buttons and badges. A large fixed value rather + /// than `Radius.circular(double.infinity)`, which `BoxDecoration` can't + /// render; any value at least half the shortest side of the element + /// yields a full pill. + static const double pill = 999; + + static BorderRadius get pillBorder => BorderRadius.all(pillRadius); + + static Radius get pillRadius => const Radius.circular(pill); } diff --git a/lib/features/about/ui/about_screen.dart b/lib/features/about/ui/about_screen.dart index ef651a10..d9540404 100644 --- a/lib/features/about/ui/about_screen.dart +++ b/lib/features/about/ui/about_screen.dart @@ -6,6 +6,7 @@ import 'package:fluttercon/common/utils/misc.dart'; import 'package:fluttercon/common/widgets/app_bar/app_bar.dart'; import 'package:fluttercon/features/about/cubit/fetch_individual_organisers_cubit.dart'; import 'package:fluttercon/features/about/ui/organising_team.dart'; +import 'package:fluttercon/features/about/widgets/about_stats_panel.dart'; import 'package:fluttercon/features/home/widgets/organizers_card.dart'; import 'package:fluttercon/l10n/l10n.dart'; @@ -30,7 +31,6 @@ class _AboutScreenState extends State { final l10n = context.l10n; return Scaffold( - backgroundColor: colorScheme.surface, appBar: const CustomAppBar(selectedIndex: 3), body: LayoutBuilder( builder: (context, constraints) { @@ -63,6 +63,13 @@ class _AboutScreenState extends State { ), ), const SliverToBoxAdapter(child: SizedBox(height: 16)), + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: AboutStatsPanel(), + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 24)), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), diff --git a/lib/features/about/ui/organising_team.dart b/lib/features/about/ui/organising_team.dart index 91cfb546..985d7f32 100644 --- a/lib/features/about/ui/organising_team.dart +++ b/lib/features/about/ui/organising_team.dart @@ -2,7 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:fluttercon/common/utils/router.dart'; import 'package:fluttercon/common/widgets/personnel_widget.dart'; +import 'package:fluttercon/common/widgets/status_view.dart'; import 'package:fluttercon/features/about/cubit/fetch_individual_organisers_cubit.dart'; +import 'package:fluttercon/l10n/l10n.dart'; import 'package:go_router/go_router.dart'; class OrganisingTeamView extends StatefulWidget { @@ -23,29 +25,58 @@ class _OrganisingTeamViewState extends State { @override Widget build(BuildContext context) { final size = MediaQuery.sizeOf(context); + final l10n = context.l10n; return BlocBuilder< FetchIndividualOrganisersCubit, FetchIndividualOrganisersState >( builder: (context, state) => state.maybeWhen( - loaded: (individualOrganisers) => SliverGrid.builder( - itemCount: individualOrganisers.length, - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 200, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - mainAxisExtent: size.width > 600 ? 200 : 140, - ), - itemBuilder: (context, index) => PersonnelWidget( - imageUrl: individualOrganisers[index].logo, - name: individualOrganisers[index].name, - designation: individualOrganisers[index].designation, - onTap: () => GoRouter.of(context).push( - FlutterConRouter.organiserDetailsRoute, - extra: individualOrganisers[index], - ), + error: (message) => SliverToBoxAdapter( + child: StatusView( + icon: Icons.wifi_off_rounded, + title: l10n.organisersErrorTitle, + message: message, + onRetry: () => context + .read() + .fetchIndividualOrganisers(forceRefresh: true), ), ), + loaded: (individualOrganisers) { + const crossAxisCount = 3; + const crossAxisSpacing = 8.0; + const horizontalPadding = 16.0; + final cellWidth = + (size.width - + horizontalPadding * 2 - + crossAxisSpacing * (crossAxisCount - 1)) / + crossAxisCount; + // Square avatar (cellWidth tall) + the 8px gap below it + the name + // line + up to two wrapped lines of tagline underneath it. + final mainAxisExtent = cellWidth + 64; + + return SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: horizontalPadding), + sliver: SliverGrid.builder( + itemCount: individualOrganisers.length, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + crossAxisSpacing: crossAxisSpacing, + mainAxisSpacing: 8, + mainAxisExtent: mainAxisExtent, + ), + itemBuilder: (context, index) => PersonnelWidget( + imageUrl: individualOrganisers[index].logo, + name: individualOrganisers[index].name, + designation: individualOrganisers[index].tagline, + maxWidth: cellWidth, + onTap: () => GoRouter.of(context).push( + FlutterConRouter.organiserDetailsRoute, + extra: individualOrganisers[index], + ), + ), + ), + ); + }, orElse: () => const SliverToBoxAdapter( child: Center(child: CircularProgressIndicator()), ), diff --git a/lib/features/about/ui/organising_team_details.dart b/lib/features/about/ui/organising_team_details.dart index 8c98bdb2..1b9e3cce 100644 --- a/lib/features/about/ui/organising_team_details.dart +++ b/lib/features/about/ui/organising_team_details.dart @@ -88,7 +88,7 @@ class OranisingTeamMemberDetailsPage extends StatelessWidget { const SizedBox(height: 60), AutoSizeText( organiser.designation, - style: const TextStyle(color: ThemeColors.orangeColor), + style: const TextStyle(color: ThemeColors.flutterconMagenta), ), AutoSizeText( organiser.name, diff --git a/lib/features/about/widgets/about_stats_panel.dart b/lib/features/about/widgets/about_stats_panel.dart new file mode 100644 index 00000000..51f50cbf --- /dev/null +++ b/lib/features/about/widgets/about_stats_panel.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:fluttercon/core/theme/inverted_panel_theme.dart'; +import 'package:fluttercon/core/theme/text_styles.dart'; +import 'package:fluttercon/core/theme/theme_colors.dart'; +import 'package:fluttercon/core/theme/theme_styles.dart'; + +class _Stat { + const _Stat(this.value, this.label); + + final String value; + final String label; +} + +/// The 2x2 stat block from the web's `components/home/About.tsx` (flutterconKE2024Web, +/// `dev` branch) — see docs/design/REBRAND-PLAN.md, Phase 2 (flutterconKEApp#259). +/// +/// Deliberately inverts color between light and dark mode: black background with +/// magenta figures in light mode, magenta background with white figures in dark +/// mode. That's the [InvertedPanelColors] theme extension from Phase 0, not a +/// hardcoded swap here — see lib/core/theme/inverted_panel_theme.dart. +class AboutStatsPanel extends StatelessWidget { + const AboutStatsPanel({super.key}); + + static const _stats = [ + _Stat('3RD', 'FLUTTERCON EDITION'), + _Stat('7TH', 'DROIDCON EDITION'), + _Stat('230+', 'SESSIONS DELIVERED'), + _Stat('3,000+', 'ATTENDEES SINCE 2018'), + ]; + + @override + Widget build(BuildContext context) { + final panelColors = Theme.of(context).extension()!; + + return ClipRRect( + borderRadius: Corners.s24Border, + child: ColoredBox( + color: panelColors.background, + child: GridView.count( + crossAxisCount: 2, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + childAspectRatio: 1.4, + children: [ + for (var i = 0; i < _stats.length; i++) + Container( + padding: const EdgeInsets.all(16), + alignment: Alignment.centerLeft, + decoration: i < 2 + ? const BoxDecoration( + border: Border( + bottom: BorderSide(color: ThemeColors.flutterconBlue), + ), + ) + : null, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _stats[i].value, + style: AppTextStyles.display( + fontSize: 28, + color: panelColors.figure, + ), + ), + const SizedBox(height: 4), + Text( + _stats[i].label, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + color: panelColors.figure, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/auth/ui/sign_in.dart b/lib/features/auth/ui/sign_in.dart index 8024cd81..41666f1b 100644 --- a/lib/features/auth/ui/sign_in.dart +++ b/lib/features/auth/ui/sign_in.dart @@ -2,6 +2,7 @@ import 'package:auth_buttons/auth_buttons.dart'; import 'package:auto_size_text/auto_size_text.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_svg/svg.dart'; import 'package:fluttercon/common/repository/firebase_repository.dart'; import 'package:fluttercon/common/utils/constants/app_assets.dart'; import 'package:fluttercon/common/utils/misc.dart'; @@ -45,7 +46,18 @@ class SignInScreen extends StatelessWidget { ); }, child: Scaffold( - backgroundColor: colorScheme.surface, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: Icon(Icons.arrow_back, color: colorScheme.onSurface), + onPressed: () { + if (GoRouter.of(context).canPop()) { + GoRouter.of(context).pop(); + } + }, + ), + ), body: SafeArea( child: Center( child: Padding( @@ -81,8 +93,11 @@ class SignInScreen extends StatelessWidget { loading: () => const Center( child: CircularProgressIndicator(), ), - orElse: () => const Image( - image: AssetImage(AppAssets.flutterConKeLogo), + orElse: () => SvgPicture.asset( + isLightMode + ? AppAssets.flutterConLogoLight + : AppAssets.flutterConLogoDark, + height: 50, ), ); }, diff --git a/lib/features/feed/ui/feed_screen.dart b/lib/features/feed/ui/feed_screen.dart index 5ed8159c..c0f01a11 100644 --- a/lib/features/feed/ui/feed_screen.dart +++ b/lib/features/feed/ui/feed_screen.dart @@ -6,7 +6,9 @@ import 'package:flutter_svg/svg.dart'; import 'package:fluttercon/common/utils/constants/app_assets.dart'; import 'package:fluttercon/common/utils/misc.dart'; import 'package:fluttercon/common/widgets/app_bar/app_bar.dart'; +import 'package:fluttercon/common/widgets/status_view.dart'; import 'package:fluttercon/core/theme/theme_colors.dart'; +import 'package:fluttercon/core/theme/theme_styles.dart'; import 'package:fluttercon/features/feed/cubit/feed_cubit.dart'; import 'package:fluttercon/features/feed/widgets/share_sheet.dart'; import 'package:fluttercon/l10n/l10n.dart'; @@ -41,23 +43,23 @@ class _FeedScreenState extends State { child: BlocBuilder( builder: (context, state) => state.maybeWhen( loaded: (fetchedFeeds) => fetchedFeeds.isEmpty - ? Center( - child: Text( - l10n.noPosts, - style: TextStyle( - color: colorScheme.onSurface, - fontSize: 20, - ), - ), + ? StatusView( + icon: Icons.dynamic_feed_outlined, + title: l10n.noPosts, + message: l10n.noPostsMessage, ) : ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 16), itemCount: fetchedFeeds.length, itemBuilder: (context, index) { final feed = fetchedFeeds[index]; - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 16, + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.secondaryContainer, + borderRadius: Corners.s32Border, + border: Border.all(color: colorScheme.primary), ), child: Column( children: [ @@ -69,10 +71,8 @@ class _FeedScreenState extends State { ), ), const SizedBox(height: 16), - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - ), + ClipRRect( + borderRadius: Corners.s16Border, child: CachedNetworkImage( fit: BoxFit.cover, imageUrl: feed.image, @@ -153,18 +153,15 @@ class _FeedScreenState extends State { ), ); }, - separatorBuilder: (context, index) => const Padding( - padding: EdgeInsets.symmetric(vertical: 16), - child: Divider(color: Color.fromARGB(50, 112, 112, 112)), - ), + separatorBuilder: (context, index) => + const SizedBox(height: 16), ), - error: (message) => AutoSizeText( - message, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - color: colorScheme.primary, - fontSize: 18, - ), + error: (message) => StatusView( + icon: Icons.wifi_off_rounded, + title: l10n.feedErrorTitle, + message: message, + onRetry: () => + context.read().fetchFeeds(forceRefresh: true), ), orElse: () => Center( child: CircularProgressIndicator( diff --git a/lib/features/feedback/ui/feedback_screen.dart b/lib/features/feedback/ui/feedback_screen.dart index afd1abe5..8d794a29 100644 --- a/lib/features/feedback/ui/feedback_screen.dart +++ b/lib/features/feedback/ui/feedback_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:fluttercon/common/utils/constants/app_assets.dart'; import 'package:fluttercon/common/utils/misc.dart'; +import 'package:fluttercon/core/theme/button_styles.dart'; import 'package:fluttercon/features/feedback/cubit/send_feedback_cubit.dart'; import 'package:fluttercon/features/feedback/widgets/emoji_container.dart'; import 'package:fluttercon/features/feedback/widgets/feedback_custom_appbar.dart'; @@ -135,15 +136,7 @@ class _FeedbackScreenState extends State { children: [ Expanded( child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: colorScheme.primary, - padding: const EdgeInsets.symmetric( - horizontal: 32, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), + style: AppButtonStyles.primary(context), onPressed: () { final feedback = feedbackController.text; if (feedback.isNotEmpty && @@ -165,8 +158,8 @@ class _FeedbackScreenState extends State { }, child: AutoSizeText( l10n.submitFeedback.toUpperCase(), - style: TextStyle( - color: colorScheme.surface, + style: const TextStyle( + color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold, ), diff --git a/lib/features/feedback/widgets/emoji_container.dart b/lib/features/feedback/widgets/emoji_container.dart index 1341838c..7db9b2c0 100644 --- a/lib/features/feedback/widgets/emoji_container.dart +++ b/lib/features/feedback/widgets/emoji_container.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:fluttercon/common/utils/misc.dart'; import 'package:fluttercon/core/theme/theme_colors.dart'; +import 'package:fluttercon/core/theme/theme_styles.dart'; class EmojiContainer extends StatelessWidget { const EmojiContainer({ @@ -23,7 +24,7 @@ class EmojiContainer extends StatelessWidget { duration: const Duration(milliseconds: 200), padding: const EdgeInsets.all(20), decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), + borderRadius: Corners.s16Border, color: isSelected ? colorScheme.primary : ThemeColors.lightGrayColor, boxShadow: [ if (onTap != null) diff --git a/lib/features/home/ui/home_screen.dart b/lib/features/home/ui/home_screen.dart index 2ba101a0..6856c5c4 100644 --- a/lib/features/home/ui/home_screen.dart +++ b/lib/features/home/ui/home_screen.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:fluttercon/common/utils/constants/app_assets.dart'; import 'package:fluttercon/common/utils/misc.dart'; import 'package:fluttercon/common/widgets/app_bar/app_bar.dart'; +import 'package:fluttercon/features/home/widgets/conf_essentials_card.dart'; +import 'package:fluttercon/features/home/widgets/conf_highlights_card.dart'; import 'package:fluttercon/features/home/widgets/organizers_card.dart'; import 'package:fluttercon/features/home/widgets/search_bar.dart'; import 'package:fluttercon/features/home/widgets/sessions_card.dart'; @@ -47,9 +49,13 @@ class _HomeScreenState extends State { const SizedBox(height: 15), ClipRRect( borderRadius: BorderRadius.circular(10), - child: Image.asset(AppAssets.droidconBanner), + child: Image.asset(AppAssets.flutterConBanner), ), const SizedBox(height: 24), + const ConfEssentialsCard(), + const SizedBox(height: 24), + const ConfHighlightsCard(), + const SizedBox(height: 24), SessionsCard(switchTab: widget.switchTab), const SizedBox(height: 24), const SpeakerCard(), diff --git a/lib/features/home/ui/speaker_details/speaker_details.dart b/lib/features/home/ui/speaker_details/speaker_details.dart index bdef28a0..e61ac1d5 100644 --- a/lib/features/home/ui/speaker_details/speaker_details.dart +++ b/lib/features/home/ui/speaker_details/speaker_details.dart @@ -1,5 +1,4 @@ import 'package:auto_size_text/auto_size_text.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; import 'package:fluttercon/common/data/models/local/local_speaker.dart'; @@ -7,6 +6,7 @@ import 'package:fluttercon/common/utils/constants/app_assets.dart'; import 'package:fluttercon/common/utils/misc.dart'; import 'package:fluttercon/common/widgets/social_handle.dart'; import 'package:fluttercon/core/theme/theme_colors.dart'; +import 'package:fluttercon/features/home/widgets/speaker_duotone_avatar.dart'; import 'package:fluttercon/l10n/l10n.dart'; import 'package:go_router/go_router.dart'; @@ -59,19 +59,7 @@ class SpeakerDetailsPage extends StatelessWidget { height: 110, width: 110, child: ClipOval( - child: CachedNetworkImage( - imageUrl: speaker.avatar, - placeholder: (_, __) => const SizedBox( - height: 150, - width: double.infinity, - child: Center(child: CircularProgressIndicator()), - ), - errorWidget: (_, __, ___) => const SizedBox( - height: 150, - width: double.infinity, - child: Icon(Icons.error, color: Colors.red), - ), - ), + child: SpeakerDuotoneAvatar(imageUrl: speaker.avatar), ), ), ), @@ -90,14 +78,16 @@ class SpeakerDetailsPage extends StatelessWidget { children: [ const Flexible( child: Icon( - Icons.android_outlined, - color: ThemeColors.orangeColor, + Icons.flutter_dash, + color: ThemeColors.flutterconMagenta, ), ), const SizedBox(width: 8), AutoSizeText( l10n.speaker, - style: const TextStyle(color: ThemeColors.orangeColor), + style: const TextStyle( + color: ThemeColors.flutterconMagenta, + ), ), ], ), diff --git a/lib/features/home/ui/speakers_list_screen.dart b/lib/features/home/ui/speakers_list_screen.dart index a2f3b5e9..771e7699 100644 --- a/lib/features/home/ui/speakers_list_screen.dart +++ b/lib/features/home/ui/speakers_list_screen.dart @@ -35,8 +35,8 @@ class SpeakerListScreen extends StatelessWidget { child: GridView.builder( itemCount: speakers.length, gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - mainAxisSpacing: 4, - crossAxisSpacing: 4, + mainAxisSpacing: 12, + crossAxisSpacing: 12, mainAxisExtent: 316, maxCrossAxisExtent: 240, ), diff --git a/lib/features/home/widgets/conf_essentials_card.dart b/lib/features/home/widgets/conf_essentials_card.dart new file mode 100644 index 00000000..d156bc97 --- /dev/null +++ b/lib/features/home/widgets/conf_essentials_card.dart @@ -0,0 +1,150 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:fluttercon/common/utils/constants/app_assets.dart'; +import 'package:fluttercon/core/theme/theme_colors.dart'; +import 'package:fluttercon/core/theme/theme_styles.dart'; +import 'package:fluttercon/l10n/l10n.dart'; + +/// Matches the web's "2026 Conf. Essentials" block on the homepage +/// (flutterconKE2024Web `dev` branch) — a blue/magenta gradient card with +/// the four conference pillars, topped by a "More Stacks. All Mobile." +/// strip of the supported platform/tech logos. +class ConfEssentialsCard extends StatelessWidget { + const ConfEssentialsCard({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + return Column( + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColorRamps.blue700, ThemeColors.flutterconMagenta], + ), + borderRadius: Corners.s24Border, + ), + child: Column( + children: [ + Align( + alignment: Alignment.centerLeft, + child: Text( + l10n.confEssentialsTitle, + style: const TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _EssentialItem( + icon: AppAssets.svgPanels, + label: l10n.essentialPanels, + ), + _EssentialItem( + icon: AppAssets.svgWorkshops, + label: l10n.essentialWorkshops, + ), + _EssentialItem( + icon: AppAssets.svgNetworking, + label: l10n.essentialNetworking, + ), + _EssentialItem( + icon: AppAssets.svgExhibitions, + label: l10n.essentialExhibitions, + ), + ], + ), + const SizedBox(height: 16), + ], + ), + ), + Transform.translate( + offset: const Offset(0, -16), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: Corners.s24Border, + border: Border.all(color: AppColorRamps.blue700), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + l10n.moreStacksAllMobile, + style: const TextStyle( + color: AppColorRamps.blue700, + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + Wrap( + spacing: 10, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + SizedBox( + height: 28, + width: 28, + child: SvgPicture.asset(AppAssets.svgKotlin), + ), + SizedBox( + height: 28, + width: 28, + child: SvgPicture.asset(AppAssets.svgSwift), + ), + SizedBox( + height: 28, + width: 28, + child: SvgPicture.asset(AppAssets.svgAi), + ), + SizedBox( + height: 28, + width: 28, + child: SvgPicture.asset(AppAssets.svgFlutter), + ), + ], + ), + ], + ), + ), + ), + ], + ); + } +} + +class _EssentialItem extends StatelessWidget { + const _EssentialItem({required this.icon, required this.label}); + + final String icon; + final String label; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + SizedBox(height: 36, width: 36, child: SvgPicture.asset(icon)), + const SizedBox(height: 8), + Text( + label, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ], + ); + } +} diff --git a/lib/features/home/widgets/conf_highlights_card.dart b/lib/features/home/widgets/conf_highlights_card.dart new file mode 100644 index 00000000..a3c799c7 --- /dev/null +++ b/lib/features/home/widgets/conf_highlights_card.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:fluttercon/common/utils/constants/app_assets.dart'; +import 'package:fluttercon/core/theme/theme_colors.dart'; +import 'package:fluttercon/core/theme/theme_styles.dart'; +import 'package:fluttercon/l10n/l10n.dart'; + +/// Matches the web's "Conf. Highlights" homepage section +/// (flutterconKE2024Web `dev` branch) — two full-bleed highlight cards, +/// "Beyond the Sessions" (blue) and "Developer Days" (black/magenta). +class ConfHighlightsCard extends StatelessWidget { + const ConfHighlightsCard({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.confHighlightsTitle, + style: const TextStyle( + color: AppColorRamps.blue700, + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + _HighlightTile( + image: AppAssets.beyondSessions, + title: l10n.beyondSessionsTitle, + description: l10n.beyondSessionsDescription, + background: AppColorRamps.blue700, + titleColor: Colors.white, + ), + const SizedBox(height: 16), + _HighlightTile( + image: AppAssets.devDays, + title: l10n.developerDaysTitle, + description: l10n.developerDaysDescription, + background: ThemeColors.blackColor, + titleColor: ThemeColors.flutterconMagenta, + ), + ], + ); + } +} + +class _HighlightTile extends StatelessWidget { + const _HighlightTile({ + required this.image, + required this.title, + required this.description, + required this.background, + required this.titleColor, + }); + + final String image; + final String title; + final String description; + final Color background; + final Color titleColor; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: background, + borderRadius: Corners.s24Border, + ), + child: Row( + children: [ + Expanded( + flex: 3, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + color: titleColor, + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + Text( + description, + style: const TextStyle(color: Colors.white, fontSize: 13), + ), + ], + ), + ), + const SizedBox(width: 12), + Expanded(flex: 2, child: Image.asset(image, fit: BoxFit.contain)), + ], + ), + ); + } +} diff --git a/lib/features/home/widgets/organizers_card.dart b/lib/features/home/widgets/organizers_card.dart index ba001792..40439f0b 100644 --- a/lib/features/home/widgets/organizers_card.dart +++ b/lib/features/home/widgets/organizers_card.dart @@ -1,11 +1,12 @@ import 'package:auto_size_text/auto_size_text.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:fluttercon/common/utils/misc.dart'; import 'package:fluttercon/common/widgets/resolved_image.dart'; +import 'package:fluttercon/common/widgets/status_view.dart'; +import 'package:fluttercon/core/theme/theme_colors.dart'; +import 'package:fluttercon/core/theme/theme_styles.dart'; import 'package:fluttercon/features/home/cubit/fetch_organisers_cubit.dart'; import 'package:fluttercon/l10n/l10n.dart'; -import 'package:sizer/sizer.dart'; class OrganizersCard extends StatefulWidget { const OrganizersCard({super.key}); @@ -24,69 +25,62 @@ class _OrganizersCardState extends State { @override Widget build(BuildContext context) { - final (isLightMode, colorScheme) = Misc.getTheme(context); - final size = MediaQuery.sizeOf(context); final l10n = context.l10n; return Container( width: double.infinity, - height: size.height * .3, - padding: const EdgeInsets.all(10), + padding: const EdgeInsets.all(20), decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - color: colorScheme.secondaryContainer, + borderRadius: Corners.s24Border, + color: ThemeColors.flutterconMagenta, ), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ - const Spacer(), AutoSizeText( l10n.organisedBy, - style: TextStyle( + style: const TextStyle( fontWeight: FontWeight.bold, - color: colorScheme.primary, - fontSize: 18, + color: ThemeColors.blackColor, + fontSize: 22, ), ), - const Spacer(), + const SizedBox(height: 16), BlocBuilder( builder: (context, state) => state.maybeWhen( - loaded: (organisers) => SizedBox( - height: size.height * .2, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemBuilder: (context, index) => Container( - height: size.height * .2, - padding: EdgeInsets.symmetric(horizontal: 8.w), - decoration: BoxDecoration( - color: isLightMode - ? colorScheme.secondaryContainer - : Colors.white, - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: isLightMode - ? Colors.transparent - : colorScheme.primary, - width: 2, + loaded: (organisers) => Wrap( + spacing: 10, + runSpacing: 10, + children: organisers + .map( + (organiser) => Container( + width: 84, + height: 84, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: Corners.s16Border, + ), + child: ResolvedImage( + imageUrl: organiser.logo, + height: 64, + width: 64, + ), ), - ), - child: ResolvedImage(imageUrl: organisers[index].logo), - ), - separatorBuilder: (_, __) => SizedBox(width: 4.w), - itemCount: organisers.length, - ), + ) + .toList(), ), - error: (message) => AutoSizeText( - message, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - color: colorScheme.primary, - fontSize: 18, - ), + error: (message) => StatusView( + icon: Icons.wifi_off_rounded, + title: l10n.organisersErrorTitle, + message: message, + onRetry: () => + context.read().fetchOrganisers(), ), orElse: () => const Center(child: CircularProgressIndicator()), ), ), - const Spacer(), ], ), ); diff --git a/lib/features/home/widgets/search_bar.dart b/lib/features/home/widgets/search_bar.dart index 6bdc305d..ba68dab5 100644 --- a/lib/features/home/widgets/search_bar.dart +++ b/lib/features/home/widgets/search_bar.dart @@ -68,7 +68,7 @@ class _SearchBarWidgetState extends State { decoration: BoxDecoration( color: colorScheme.surface, borderRadius: BorderRadius.circular(35), - border: Border.all(color: ThemeColors.blueDroidconColor), + border: Border.all(color: ThemeColors.flutterconBlue), ), child: Row( children: [ @@ -106,7 +106,7 @@ class _SearchBarWidgetState extends State { error: (message) => Center( child: AutoSizeText( message, - style: const TextStyle(color: ThemeColors.orangeDroidconColor), + style: const TextStyle(color: ThemeColors.flutterconMagenta), ), ), ); diff --git a/lib/features/home/widgets/sessions_card.dart b/lib/features/home/widgets/sessions_card.dart index 9f1a1684..d571b3f8 100644 --- a/lib/features/home/widgets/sessions_card.dart +++ b/lib/features/home/widgets/sessions_card.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:fluttercon/common/utils/misc.dart'; import 'package:fluttercon/common/utils/router.dart'; +import 'package:fluttercon/common/widgets/status_view.dart'; import 'package:fluttercon/core/theme/theme_colors.dart'; import 'package:fluttercon/features/home/cubit/fetch_sessions_cubit.dart'; import 'package:fluttercon/l10n/l10n.dart'; @@ -63,7 +64,7 @@ class _SessionsCardState extends State { decoration: BoxDecoration( color: (isLightMode - ? ThemeColors.blueColor + ? ThemeColors.flutterconBlue : ThemeColors.lightGrayColor) .withValues(alpha: .11), borderRadius: BorderRadius.circular(50), @@ -82,6 +83,7 @@ class _SessionsCardState extends State { : ThemeColors.lightGrayColor, ), ), + error: (_) => const SizedBox.shrink(), orElse: () => const SizedBox( height: 16, width: 16, @@ -181,6 +183,14 @@ class _SessionsCardState extends State { ); }, ), + error: (message) => StatusView( + icon: Icons.wifi_off_rounded, + title: l10n.sessionsErrorTitle, + message: message, + onRetry: () => context.read().fetchSessions( + forceRefresh: true, + ), + ), orElse: () => const Center( child: SizedBox( height: 32, diff --git a/lib/features/home/widgets/speaker_duotone_avatar.dart b/lib/features/home/widgets/speaker_duotone_avatar.dart new file mode 100644 index 00000000..27e94456 --- /dev/null +++ b/lib/features/home/widgets/speaker_duotone_avatar.dart @@ -0,0 +1,92 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:fluttercon/core/theme/theme_colors.dart'; + +// Grayscale + ~1.25x contrast boost, applied before the blue duotone blend +// below. Standard luminance-weighted grayscale row repeated across R/G/B, +// then rescaled around the 0.5 midpoint for the contrast bump. +const _kContrast = 1.25; +const _kContrastOffset = (1 - _kContrast) / 2 * 255; +const List _grayscaleContrastMatrix = [ + 0.2126 * _kContrast, + 0.7152 * _kContrast, + 0.0722 * _kContrast, + 0, + _kContrastOffset, // + 0.2126 * _kContrast, + 0.7152 * _kContrast, + 0.0722 * _kContrast, + 0, + _kContrastOffset, // + 0.2126 * _kContrast, + 0.7152 * _kContrast, + 0.0722 * _kContrast, + 0, + _kContrastOffset, // + 0, 0, 0, 1, 0, +]; + +/// Speaker photo treatment from the web's `components/speakers/SpeakerCard.tsx` +/// (flutterconKE2024Web, `dev` branch): grayscale + contrast, screen-blended +/// over blue (CSS `mix-blend-screen` against a `bg-blue-600` backdrop), with a +/// faint repeating dot overlay. See docs/design/REBRAND-PLAN.md, Phase 3 +/// (flutterconKEApp#260). +/// +/// Renders unclipped — wrap in `ClipRRect`/`ClipOval` as the call site +/// already does for its own avatar shape. +class SpeakerDuotoneAvatar extends StatelessWidget { + const SpeakerDuotoneAvatar({required this.imageUrl, super.key}); + + final String imageUrl; + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: AppColorRamps.blue600, + child: Stack( + fit: StackFit.expand, + children: [ + ColorFiltered( + colorFilter: const ColorFilter.mode( + AppColorRamps.blue600, + BlendMode.screen, + ), + child: ColorFiltered( + colorFilter: const ColorFilter.matrix(_grayscaleContrastMatrix), + child: CachedNetworkImage( + fit: BoxFit.cover, + imageUrl: imageUrl, + placeholder: (_, __) => + const Center(child: CircularProgressIndicator()), + errorWidget: (_, __, ___) => + const Icon(Icons.error, color: Colors.red), + ), + ), + ), + IgnorePointer(child: CustomPaint(painter: _DotOverlayPainter())), + ], + ), + ); + } +} + +class _DotOverlayPainter extends CustomPainter { + static const _spacing = 6.0; + static const _dotRadius = 0.7; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = Colors.white.withValues(alpha: 0.3) + ..blendMode = BlendMode.overlay; + + for (var y = _spacing / 2; y < size.height; y += _spacing) { + for (var x = _spacing / 2; x < size.width; x += _spacing) { + canvas.drawCircle(Offset(x, y), _dotRadius, paint); + } + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} diff --git a/lib/features/home/widgets/speaker_grid_tile.dart b/lib/features/home/widgets/speaker_grid_tile.dart index 4594750a..2b0f908e 100644 --- a/lib/features/home/widgets/speaker_grid_tile.dart +++ b/lib/features/home/widgets/speaker_grid_tile.dart @@ -1,11 +1,11 @@ import 'package:auto_size_text/auto_size_text.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:fluttercon/common/data/models/local/local_speaker.dart'; import 'package:fluttercon/common/utils/misc.dart'; import 'package:fluttercon/common/utils/router.dart'; import 'package:fluttercon/core/theme/theme_colors.dart'; import 'package:fluttercon/core/theme/theme_styles.dart'; +import 'package:fluttercon/features/home/widgets/speaker_duotone_avatar.dart'; import 'package:fluttercon/l10n/l10n.dart'; import 'package:go_router/go_router.dart'; @@ -18,9 +18,12 @@ class SpeakerGridTile extends StatelessWidget { final l10n = context.l10n; final (isLightMode, colorScheme) = Misc.getTheme(context); - return Card( - elevation: 4, - color: colorScheme.secondaryContainer, + return Container( + decoration: BoxDecoration( + color: colorScheme.secondaryContainer, + borderRadius: Corners.s32Border, + border: Border.all(color: colorScheme.primary), + ), child: Padding( padding: const EdgeInsets.all(20), child: Column( @@ -30,25 +33,17 @@ class SpeakerGridTile extends StatelessWidget { maxWidth: MediaQuery.sizeOf(context).width / 4.5, ), decoration: BoxDecoration( - border: Border.all(color: ThemeColors.tealColor, width: 2), + border: Border.all( + color: ThemeColors.flutterconMagenta, + width: 2, + ), borderRadius: Corners.s12Border, ), - child: ClipRRect( - borderRadius: Corners.s10Border, - child: CachedNetworkImage( - fit: BoxFit.cover, - imageUrl: speaker.avatar, - height: 100, - placeholder: (_, __) => const SizedBox( - height: 150, - width: double.infinity, - child: Center(child: CircularProgressIndicator()), - ), - errorWidget: (_, __, ___) => const SizedBox( - height: 150, - width: double.infinity, - child: Icon(Icons.error, color: Colors.red), - ), + child: AspectRatio( + aspectRatio: 1, + child: ClipRRect( + borderRadius: Corners.s10Border, + child: SpeakerDuotoneAvatar(imageUrl: speaker.avatar), ), ), ), @@ -83,7 +78,7 @@ class SpeakerGridTile extends StatelessWidget { borderRadius: BorderRadius.circular(8), ), side: const BorderSide( - color: ThemeColors.blueGreenDroidconColor, + color: ThemeColors.flutterconMagenta, width: 2, ), backgroundColor: colorScheme.surface, @@ -91,7 +86,7 @@ class SpeakerGridTile extends StatelessWidget { child: AutoSizeText( l10n.details.toUpperCase(), style: const TextStyle( - color: ThemeColors.blueGreenDroidconColor, + color: ThemeColors.flutterconMagenta, fontWeight: FontWeight.bold, ), ), diff --git a/lib/features/home/widgets/speaker_home_card.dart b/lib/features/home/widgets/speaker_home_card.dart index c68a4ba2..158c3ca9 100644 --- a/lib/features/home/widgets/speaker_home_card.dart +++ b/lib/features/home/widgets/speaker_home_card.dart @@ -37,7 +37,7 @@ class _SpeakerCardState extends State { l10n.speakers, style: Theme.of(context).textTheme.titleLarge!.copyWith( color: isLightMode - ? ThemeColors.blueDroidconColor + ? ThemeColors.flutterconBlue : colorScheme.onSurface, fontWeight: FontWeight.bold, ), @@ -57,7 +57,7 @@ class _SpeakerCardState extends State { decoration: BoxDecoration( color: (isLightMode - ? ThemeColors.blueColor + ? ThemeColors.flutterconBlue : ThemeColors.lightGrayColor) .withValues(alpha: .11), borderRadius: BorderRadius.circular(50), @@ -122,7 +122,7 @@ class _SpeakerCardState extends State { message, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, - color: ThemeColors.blueColor, + color: ThemeColors.flutterconBlue, fontSize: 18, ), ), diff --git a/lib/features/home/widgets/sponsors_card.dart b/lib/features/home/widgets/sponsors_card.dart index 0e79cf1a..a67ec109 100644 --- a/lib/features/home/widgets/sponsors_card.dart +++ b/lib/features/home/widgets/sponsors_card.dart @@ -3,10 +3,33 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:fluttercon/common/data/enums/sponsor_type.dart'; import 'package:fluttercon/common/utils/misc.dart'; +import 'package:fluttercon/common/widgets/dashed_divider.dart'; import 'package:fluttercon/common/widgets/resolved_image.dart'; +import 'package:fluttercon/common/widgets/status_view.dart'; +import 'package:fluttercon/core/theme/theme_colors.dart'; +import 'package:fluttercon/core/theme/theme_styles.dart'; import 'package:fluttercon/features/home/cubit/fetch_sponsors_cubit.dart'; import 'package:fluttercon/l10n/l10n.dart'; -import 'package:sizer/sizer.dart'; + +/// Tiers that get their own named section, in display order. Anything not +/// in this list (swag/startup/venue/etc.) is still shown, grouped with +/// "everything else" after silver/bronze — mirrors the web's +/// `SponsorsList.tsx` (`REMAINING_TIER_ORDER`), minus its platinum/gold +/// special-casing which collapses into the same per-tier section here. +const _tierDisplayOrder = [ + SponsorType.platinum, + SponsorType.gold, + SponsorType.silver, + SponsorType.bronze, + SponsorType.swag, + SponsorType.startup, + SponsorType.venue, +]; + +String _tierLabel(SponsorType type) { + final name = type.name; + return name[0].toUpperCase() + name.substring(1); +} class SponsorsCard extends StatefulWidget { const SponsorsCard({super.key}); @@ -24,20 +47,19 @@ class _SponsorsCardState extends State { @override Widget build(BuildContext context) { - final size = MediaQuery.sizeOf(context); final l10n = context.l10n; final (isLightMode, colorScheme) = Misc.getTheme(context); return Container( width: double.infinity, - height: size.height * .35, - padding: const EdgeInsets.all(10), + padding: const EdgeInsets.all(16), decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), + borderRadius: Corners.s24Border, color: colorScheme.secondaryContainer, + border: Border.all(color: colorScheme.primary), ), child: Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + mainAxisSize: MainAxisSize.min, children: [ AutoSizeText( l10n.sponsors, @@ -47,77 +69,95 @@ class _SponsorsCardState extends State { fontSize: 18, ), ), + const SizedBox(height: 16), BlocBuilder( builder: (context, state) => state.maybeWhen( loaded: (sponsors) { - final nonPlatinumSponsors = sponsors - .where((sponsor) => sponsor.sponsorType != SponsorType.gold) + if (sponsors.isEmpty) { + return const SizedBox.shrink(); + } + + final tiersPresent = _tierDisplayOrder + .where( + (tier) => sponsors.any( + (sponsor) => sponsor.sponsorType == tier, + ), + ) .toList(); + final knownTiers = _tierDisplayOrder.toSet(); + final otherTiers = sponsors + .map((sponsor) => sponsor.sponsorType) + .where((tier) => !knownTiers.contains(tier)) + .toSet(); + return Column( children: [ - Container( - padding: EdgeInsets.symmetric(horizontal: 8.w), - decoration: BoxDecoration( - color: isLightMode - ? colorScheme.secondaryContainer - : Colors.white, - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: isLightMode - ? Colors.transparent - : colorScheme.primary, - width: 2, - ), - ), - child: ResolvedImage( - imageUrl: sponsors - .firstWhere( - (sponsor) => - sponsor.sponsorType == SponsorType.gold, - ) - .logo, - ), - ), - const SizedBox(height: 16), - SizedBox( - height: size.height / 10, - child: ListView.builder( - shrinkWrap: true, - scrollDirection: Axis.horizontal, - itemCount: nonPlatinumSponsors.length, - itemBuilder: (context, index) => Container( - padding: EdgeInsets.symmetric(horizontal: 8.w), - decoration: BoxDecoration( - color: isLightMode - ? colorScheme.secondaryContainer - : Colors.white, - borderRadius: BorderRadius.circular(10), - border: Border.all( + for (final tier in [...tiersPresent, ...otherTiers]) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Column( + children: [ + DashedDivider( color: isLightMode - ? Colors.transparent - : colorScheme.primary, - width: 2, + ? ThemeColors.greyAccentColor + : ThemeColors.lightGreyTextColor, + ), + const SizedBox(height: 16), + AutoSizeText( + _tierLabel(tier), + style: TextStyle( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + fontSize: 15, + ), ), - ), - child: SizedBox( - width: size.width / 4, - child: ResolvedImage( - imageUrl: nonPlatinumSponsors[index].logo, + const SizedBox(height: 12), + Wrap( + spacing: 12, + runSpacing: 12, + alignment: WrapAlignment.center, + children: sponsors + .where( + (sponsor) => sponsor.sponsorType == tier, + ) + .map( + (sponsor) => Container( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), + decoration: BoxDecoration( + color: isLightMode + ? ThemeColors + .lightGrayBackgroundColor + : Colors.white, + borderRadius: Corners.s16Border, + ), + child: ResolvedImage( + imageUrl: sponsor.logo, + height: tier == SponsorType.platinum + ? 56 + : 40, + width: tier == SponsorType.platinum + ? 160 + : 120, + ), + ), + ) + .toList(), ), - ), + ], ), ), - ), - const SizedBox(height: 16), ], ); }, - error: (message) => AutoSizeText( - message, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - color: colorScheme.primary, - fontSize: 18, + error: (message) => StatusView( + icon: Icons.wifi_off_rounded, + title: l10n.sponsorsErrorTitle, + message: message, + onRetry: () => context.read().fetchSponsors( + forceRefresh: true, ), ), orElse: () => const Center(child: CircularProgressIndicator()), diff --git a/lib/features/sessions/ui/session_details/session_details.dart b/lib/features/sessions/ui/session_details/session_details.dart index e708fed8..7bdf3c2b 100644 --- a/lib/features/sessions/ui/session_details/session_details.dart +++ b/lib/features/sessions/ui/session_details/session_details.dart @@ -54,14 +54,16 @@ class SessionDetailsPage extends StatelessWidget { children: [ const Flexible( child: Icon( - Icons.android_outlined, - color: ThemeColors.orangeColor, + Icons.flutter_dash, + color: ThemeColors.flutterconMagenta, ), ), const SizedBox(width: 8), AutoSizeText( l10n.speaker, - style: const TextStyle(color: ThemeColors.orangeColor), + style: const TextStyle( + color: ThemeColors.flutterconMagenta, + ), ), ], ), @@ -108,7 +110,7 @@ class SessionDetailsPage extends StatelessWidget { ? Icons.star_rate_rounded : Icons.star_border_outlined, color: status == BookmarkStatus.bookmarked - ? ThemeColors.orangeColor + ? ThemeColors.flutterconMagenta : colorScheme.primary, size: 32, ), @@ -129,7 +131,7 @@ class SessionDetailsPage extends StatelessWidget { ? Icons.star_rate_rounded : Icons.star_border_outlined, color: session.isBookmarked - ? ThemeColors.orangeColor + ? ThemeColors.flutterconMagenta : colorScheme.primary, size: 32, ), @@ -215,7 +217,7 @@ class SessionDetailsPage extends StatelessWidget { ), floatingActionButton: FloatingActionButton( shape: const CircleBorder(), - backgroundColor: ThemeColors.orangeColor, + backgroundColor: ThemeColors.flutterconMagenta, child: BlocBuilder( builder: (context, state) => state.maybeWhen( orElse: () => Transform.flip( diff --git a/lib/features/sessions/ui/sessions_screen.dart b/lib/features/sessions/ui/sessions_screen.dart index cdc29a89..936d591f 100644 --- a/lib/features/sessions/ui/sessions_screen.dart +++ b/lib/features/sessions/ui/sessions_screen.dart @@ -4,10 +4,12 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:fluttercon/common/data/enums/bookmark_status.dart'; import 'package:fluttercon/common/utils/misc.dart'; import 'package:fluttercon/common/widgets/app_bar/app_bar.dart'; +import 'package:fluttercon/common/widgets/status_view.dart'; import 'package:fluttercon/core/theme/theme_colors.dart'; import 'package:fluttercon/features/sessions/cubit/fetch_grouped_sessions_cubit.dart'; import 'package:fluttercon/features/sessions/ui/widgets/day_sessions_view.dart'; import 'package:fluttercon/features/sessions/ui/widgets/day_tab_view.dart'; +import 'package:fluttercon/features/sessions/ui/widgets/session_view_toolbar.dart'; import 'package:fluttercon/l10n/l10n.dart'; import 'package:intl/intl.dart'; @@ -38,27 +40,34 @@ class _SessionsScreenState extends State final (isLightMode, colorScheme) = Misc.getTheme(context); return Scaffold( - appBar: CustomAppBar( - selectedIndex: 2, - selectedScheduleIndex: _viewIndex, - onCompactTapped: () => setState(() { - _viewIndex = 0; - }), - onScheduleTapped: () => setState(() { - _viewIndex = 1; - }), - ), + appBar: const CustomAppBar(selectedIndex: 2), body: BlocBuilder( builder: (context, state) => state.maybeWhen( - orElse: () => const Padding( - padding: EdgeInsets.symmetric(horizontal: 16), - child: CircularProgressIndicator(), + orElse: () => const Center(child: CircularProgressIndicator()), + error: (message) => StatusView( + icon: Icons.wifi_off_rounded, + title: l10n.sessionsErrorTitle, + message: message, + onRetry: () => context + .read() + .fetchGroupedSessions(forceRefresh: true), ), loaded: (groupedSessions) => DefaultTabController( length: groupedSessions.length, child: SafeArea( child: NestedScrollView( headerSliverBuilder: (context, innerBoxIsScrolled) => [ + SliverToBoxAdapter( + child: SessionViewToolbar( + selectedScheduleIndex: _viewIndex, + onCompactTapped: () => setState(() { + _viewIndex = 0; + }), + onScheduleTapped: () => setState(() { + _viewIndex = 1; + }), + ), + ), SliverToBoxAdapter( child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -127,10 +136,12 @@ class _SessionsScreenState extends State }, trackOutlineWidth: WidgetStateProperty.all(1), trackColor: WidgetStateProperty.all( - Colors.black, + ThemeColors.flutterconBlue, ), - activeTrackColor: ThemeColors.orangeColor, - activeThumbColor: ThemeColors.orangeColor, + activeTrackColor: + ThemeColors.flutterconMagenta, + activeThumbColor: + ThemeColors.flutterconMagenta, thumbColor: WidgetStateProperty.all( Colors.white, ), diff --git a/lib/features/sessions/ui/widgets/compact_view_card.dart b/lib/features/sessions/ui/widgets/compact_view_card.dart index 149dfedf..13cc0f2f 100644 --- a/lib/features/sessions/ui/widgets/compact_view_card.dart +++ b/lib/features/sessions/ui/widgets/compact_view_card.dart @@ -3,12 +3,19 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:fluttercon/common/data/models/local/local_session.dart'; import 'package:fluttercon/common/utils/misc.dart'; +import 'package:fluttercon/common/widgets/pill_badge.dart'; import 'package:fluttercon/core/theme/theme_colors.dart'; +import 'package:fluttercon/core/theme/theme_styles.dart'; import 'package:fluttercon/features/sessions/cubit/bookmark_session_cubit.dart'; import 'package:fluttercon/features/sessions/cubit/fetch_grouped_sessions_cubit.dart'; +import 'package:fluttercon/features/sessions/ui/widgets/session_time_badge.dart'; import 'package:fluttercon/l10n/l10n.dart'; import 'package:intl/intl.dart'; +/// Matches the web's `SessionListCard.tsx` (flutterconKE2024Web, `dev` +/// branch) — rounded-4xl card, primary-colored border, time-badge box, +/// level/format pill badges. See docs/design/REBRAND-PLAN.md, Phase 3 +/// (flutterconKEApp#260). class CompactViewCard extends StatelessWidget { const CompactViewCard({ required this.session, @@ -22,157 +29,174 @@ class CompactViewCard extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = context.l10n; - final (isLightMode, colorScheme) = Misc.getTheme(context); + final (_, colorScheme) = Misc.getTheme(context); - return Card( - elevation: 2, - color: isLightMode ? colorScheme.surface : colorScheme.secondaryContainer, - child: ListTile( - leading: AutoSizeText.rich( - TextSpan( - children: [ - TextSpan( - text: - '${DateFormat.Hm().format(DateTime.parse('2022-01-01 ${session.startTime}'))}\n', - style: const TextStyle(fontSize: 18), - ), - TextSpan( - text: - DateTime.parse('2022-01-01 ${session.startTime}').hour > 11 - ? 'PM' - : 'AM', - style: TextStyle(fontSize: 18, color: colorScheme.onSurface), - ), - ], - ), - textAlign: TextAlign.center, - ), - title: AutoSizeText( - session.title, - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - color: colorScheme.onSurface, - ), - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 8), - AutoSizeText( - session.description, - style: TextStyle(fontSize: 16, color: colorScheme.onSurface), - maxLines: 3, - ), - if (session.rooms.isNotEmpty) const SizedBox(height: 8), - if (session.rooms.isNotEmpty) - AutoSizeText( - l10n.sessionFullTimeAndVenue( - DateFormat.Hm().format(session.startDateTime), - DateFormat.Hm().format(session.endDateTime), - session.rooms - .map((room) => room.title) - .join(', ') - .toUpperCase(), + return Container( + decoration: BoxDecoration( + color: colorScheme.secondaryContainer, + borderRadius: Corners.s32Border, + border: Border.all(color: colorScheme.primary), + ), + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SessionTimeBadge(startDateTime: session.startDateTime), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AutoSizeText( + session.isKeynote + ? '${l10n.keynote}: ${session.title}' + : session.title, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: colorScheme.onSurface, + ), ), - style: TextStyle(color: colorScheme.onSurface), - ), - if (session.speakers.isNotEmpty) const SizedBox(height: 8), - if (session.speakers.isNotEmpty) - Row( - children: [ - Flexible( - child: Icon( - Icons.android_outlined, - color: colorScheme.primary, - ), + if (!session.isServiceSession) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + PillBadge( + label: session.isKeynote + ? '#${l10n.keynote}' + : '#${session.sessionLevel}', + variant: PillBadgeVariant.level, + ), + PillBadge( + label: session.sessionFormat, + variant: PillBadgeVariant.format, + ), + ], ), - const SizedBox(width: 8), - Flexible( - flex: 8, - child: AutoSizeText( - session.speakers - .map((speaker) => speaker.name) - .join(', '), - style: TextStyle(color: colorScheme.primary), + ], + if (session.description.isNotEmpty) ...[ + const SizedBox(height: 8), + AutoSizeText( + session.description, + style: TextStyle( + fontSize: 14, + color: colorScheme.onSurface, ), + maxLines: 3, ), ], - ), - ], - ), - trailing: BlocConsumer( - listener: (context, state) { - state.mapOrNull( - loaded: (loaded) { - ScaffoldMessenger.of( + const SizedBox(height: 8), + AutoSizeText( + l10n.sessionFullTimeAndVenue( + DateFormat.Hm().format(session.startDateTime), + DateFormat.Hm().format(session.endDateTime), + session.rooms + .map((room) => room.title) + .join(', ') + .toUpperCase(), + ), + style: const TextStyle( + fontSize: 12, + color: ThemeColors.greyTextColor, + ), + ), + if (session.speakers.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 12, + runSpacing: 4, + children: session.speakers + .map( + (speaker) => Text( + speaker.name ?? '', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: colorScheme.primary, + ), + ), + ) + .toList(), + ), + ], + ], + ), + ), + BlocConsumer( + listener: (context, state) { + state.mapOrNull( + loaded: (loaded) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: AutoSizeText(loaded.message)), + ); + }, + error: (error) => ScaffoldMessenger.of( context, - ).showSnackBar(SnackBar(content: AutoSizeText(loaded.message))); - }, - error: (error) => ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: AutoSizeText(error.message))), - ); - }, - builder: (context, state) { - return state.maybeWhen( - loading: (loadingIndex) => loadingIndex == listIndex - ? const SizedBox( - height: 32, - width: 32, - child: CircularProgressIndicator(), - ) - : IconButton( - onPressed: () => context - .read() - .bookmarkSession( - sessionId: session.serverId, - index: listIndex, - ) - .then((_) { - if (context.mounted) { - context - .read() - .fetchGroupedSessions(); - } - }), - icon: Icon( - session.isBookmarked - ? Icons.star_rate_rounded - : Icons.star_border_outlined, - color: session.isBookmarked - ? ThemeColors.orangeColor - : colorScheme.primary, - size: 32, + ).showSnackBar(SnackBar(content: AutoSizeText(error.message))), + ); + }, + builder: (context, state) { + return state.maybeWhen( + loading: (loadingIndex) => loadingIndex == listIndex + ? const SizedBox( + height: 32, + width: 32, + child: CircularProgressIndicator(), + ) + : IconButton( + onPressed: () => context + .read() + .bookmarkSession( + sessionId: session.serverId, + index: listIndex, + ) + .then((_) { + if (context.mounted) { + context + .read() + .fetchGroupedSessions(); + } + }), + icon: Icon( + session.isBookmarked + ? Icons.star_rate_rounded + : Icons.star_border_outlined, + color: session.isBookmarked + ? ThemeColors.flutterconMagenta + : colorScheme.primary, + size: 32, + ), ), - ), - orElse: () => IconButton( - onPressed: () => context - .read() - .bookmarkSession( - sessionId: session.serverId, - index: listIndex, - ) - .then((_) { - if (context.mounted) { - context - .read() - .fetchGroupedSessions(); - } - }), - icon: Icon( - session.isBookmarked - ? Icons.star_rate_rounded - : Icons.star_border_outlined, - color: session.isBookmarked - ? ThemeColors.orangeColor - : colorScheme.primary, - size: 32, + orElse: () => IconButton( + onPressed: () => context + .read() + .bookmarkSession( + sessionId: session.serverId, + index: listIndex, + ) + .then((_) { + if (context.mounted) { + context + .read() + .fetchGroupedSessions(); + } + }), + icon: Icon( + session.isBookmarked + ? Icons.star_rate_rounded + : Icons.star_border_outlined, + color: session.isBookmarked + ? ThemeColors.flutterconMagenta + : colorScheme.primary, + size: 32, + ), ), - ), - ); - }, - ), + ); + }, + ), + ], ), ); } diff --git a/lib/features/sessions/ui/widgets/day_sessions_view.dart b/lib/features/sessions/ui/widgets/day_sessions_view.dart index 07384046..f77b8889 100644 --- a/lib/features/sessions/ui/widgets/day_sessions_view.dart +++ b/lib/features/sessions/ui/widgets/day_sessions_view.dart @@ -37,20 +37,20 @@ class DaySessionsView extends StatelessWidget { afterLineStyle: LineStyle( thickness: 2, color: randomizeColor - ? ThemeColors.orangeColor - : ThemeColors.blueGreenDroidconColor, + ? ThemeColors.flutterconMagenta + : ThemeColors.flutterconMagenta, ), beforeLineStyle: LineStyle( thickness: 2, color: randomizeColor - ? ThemeColors.orangeColor - : ThemeColors.blueGreenDroidconColor, + ? ThemeColors.flutterconMagenta + : ThemeColors.flutterconMagenta, ), indicatorStyle: IndicatorStyle( width: 8, color: randomizeColor - ? ThemeColors.orangeColor - : ThemeColors.blueGreenDroidconColor, + ? ThemeColors.flutterconMagenta + : ThemeColors.flutterconMagenta, ), ), ), diff --git a/lib/features/sessions/ui/widgets/day_tab_view.dart b/lib/features/sessions/ui/widgets/day_tab_view.dart index ce9c9df1..c293c8c3 100644 --- a/lib/features/sessions/ui/widgets/day_tab_view.dart +++ b/lib/features/sessions/ui/widgets/day_tab_view.dart @@ -27,8 +27,8 @@ class DayTabView extends StatelessWidget { decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), color: isActive - ? ThemeColors.orangeDroidconColor - : ThemeColors.blueGreenDroidconColor.withValues(alpha: .1), + ? ThemeColors.flutterconMagenta + : ThemeColors.flutterconMagenta.withValues(alpha: .1), ), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), child: Column( diff --git a/lib/features/sessions/ui/widgets/schedule_view_card.dart b/lib/features/sessions/ui/widgets/schedule_view_card.dart index 81756cb7..6a5f71e0 100644 --- a/lib/features/sessions/ui/widgets/schedule_view_card.dart +++ b/lib/features/sessions/ui/widgets/schedule_view_card.dart @@ -6,10 +6,12 @@ import 'package:fluttercon/common/data/enums/bookmark_status.dart'; import 'package:fluttercon/common/data/models/local/local_session.dart'; import 'package:fluttercon/common/utils/misc.dart'; import 'package:fluttercon/common/utils/router.dart'; +import 'package:fluttercon/common/widgets/pill_badge.dart'; import 'package:fluttercon/core/theme/theme_colors.dart'; import 'package:fluttercon/core/theme/theme_styles.dart'; import 'package:fluttercon/features/sessions/cubit/bookmark_session_cubit.dart'; import 'package:fluttercon/features/sessions/cubit/fetch_grouped_sessions_cubit.dart'; +import 'package:fluttercon/features/sessions/ui/widgets/session_time_badge.dart'; import 'package:fluttercon/l10n/l10n.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; @@ -28,60 +30,91 @@ class ScheduleViewCard extends StatelessWidget { final l10n = context.l10n; final (isLightMode, colorScheme) = Misc.getTheme(context); - return Card( - elevation: 0, - color: colorScheme.secondaryContainer, - shadowColor: Colors.transparent, + return Container( + decoration: BoxDecoration( + color: colorScheme.secondaryContainer, + borderRadius: Corners.s32Border, + border: Border.all(color: colorScheme.primary), + ), + clipBehavior: Clip.antiAlias, child: SizedBox( width: double.infinity, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ClipRRect( - borderRadius: BorderRadius.circular(10), - child: CachedNetworkImage( - imageUrl: session.sessionImage, + CachedNetworkImage( + imageUrl: session.sessionImage, + height: 150, + width: double.infinity, + fit: BoxFit.cover, + placeholder: (_, __) => const SizedBox( height: 150, width: double.infinity, - fit: BoxFit.cover, - placeholder: (_, __) => const SizedBox( - height: 150, - width: double.infinity, - child: Center(child: CircularProgressIndicator()), - ), - errorWidget: (_, __, ___) => const SizedBox( - height: 150, - width: double.infinity, - child: Icon(Icons.error, color: Colors.red), - ), + child: Center(child: CircularProgressIndicator()), + ), + errorWidget: (_, __, ___) => const SizedBox( + height: 150, + width: double.infinity, + child: Icon(Icons.error, color: Colors.red), ), - ), - const SizedBox(height: 16), - Row( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: AutoSizeText( - l10n.sessionTimeAndVenue( - DateFormat.Hm().format(session.startDateTime), - session.rooms.map((room) => room.title).join(', '), - ), - style: const TextStyle(color: Colors.grey, fontSize: 14), - ), - ), - ], ), const SizedBox(height: 16), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), - child: AutoSizeText( - session.title, - maxLines: 2, - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - color: colorScheme.onSurface, - ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SessionTimeBadge(startDateTime: session.startDateTime), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AutoSizeText( + session.isKeynote + ? '${l10n.keynote}: ${session.title}' + : session.title, + maxLines: 2, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: colorScheme.onSurface, + ), + ), + if (!session.isServiceSession) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + PillBadge( + label: session.isKeynote + ? '#${l10n.keynote}' + : '#${session.sessionLevel}', + variant: PillBadgeVariant.level, + ), + PillBadge( + label: session.sessionFormat, + variant: PillBadgeVariant.format, + ), + ], + ), + ], + const SizedBox(height: 8), + AutoSizeText( + l10n.sessionTimeAndVenue( + DateFormat.Hm().format(session.startDateTime), + session.rooms.map((room) => room.title).join(', '), + ), + style: const TextStyle( + color: Colors.grey, + fontSize: 12, + ), + ), + ], + ), + ), + ], ), ), const SizedBox(height: 5), @@ -106,7 +139,7 @@ class ScheduleViewCard extends StatelessWidget { margin: const EdgeInsets.only(right: 8), decoration: BoxDecoration( border: Border.all( - color: ThemeColors.tealColor, + color: ThemeColors.flutterconMagenta, width: 2, ), borderRadius: Corners.s12Border, @@ -161,7 +194,7 @@ class ScheduleViewCard extends StatelessWidget { ? Icons.star_rate_rounded : Icons.star_border_outlined, color: status == BookmarkStatus.bookmarked - ? ThemeColors.orangeColor + ? ThemeColors.flutterconMagenta : colorScheme.primary, size: 32, ), @@ -182,7 +215,7 @@ class ScheduleViewCard extends StatelessWidget { ? Icons.star_rate_rounded : Icons.star_border_outlined, color: session.isBookmarked - ? ThemeColors.orangeColor + ? ThemeColors.flutterconMagenta : colorScheme.primary, size: 32, ), diff --git a/lib/features/sessions/ui/widgets/session_time_badge.dart b/lib/features/sessions/ui/widgets/session_time_badge.dart new file mode 100644 index 00000000..4ac63530 --- /dev/null +++ b/lib/features/sessions/ui/widgets/session_time_badge.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:fluttercon/common/utils/misc.dart'; +import 'package:fluttercon/core/theme/theme_colors.dart'; +import 'package:fluttercon/core/theme/theme_styles.dart'; +import 'package:intl/intl.dart'; + +/// The rounded time-badge box from the web's `SessionListCard.tsx` +/// (flutterconKE2024Web, `dev` branch) — see docs/design/REBRAND-PLAN.md, +/// Phase 3 (flutterconKEApp#260). A small rounded-2xl box showing the +/// session start time with AM/PM stacked below it. +class SessionTimeBadge extends StatelessWidget { + const SessionTimeBadge({required this.startDateTime, super.key}); + + final DateTime startDateTime; + + @override + Widget build(BuildContext context) { + final (isLightMode, _) = Misc.getTheme(context); + + return Container( + width: 64, + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: isLightMode + ? AppColorRamps.blue50 + : ThemeColors.flutterconBlue.withValues(alpha: 0.15), + borderRadius: Corners.s16Border, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + DateFormat('h:mm').format(startDateTime), + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + height: 1, + // blue700 (flutterconBlue) on blue50 is only 3.2:1 — below + // WCAG AA for this text size. blue800 keeps the same + // brand-blue family while clearing 4.5:1. + color: isLightMode + ? AppColorRamps.blue800 + : ThemeColors.flutterconMagenta, + ), + ), + const SizedBox(height: 4), + Text( + DateFormat('a').format(startDateTime), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: isLightMode + ? ThemeColors.greyTextColor + : ThemeColors.lightGreyTextColor, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/sessions/ui/widgets/session_view_toolbar.dart b/lib/features/sessions/ui/widgets/session_view_toolbar.dart new file mode 100644 index 00000000..5a454e73 --- /dev/null +++ b/lib/features/sessions/ui/widgets/session_view_toolbar.dart @@ -0,0 +1,185 @@ +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:fluttercon/common/utils/constants/app_assets.dart'; +import 'package:fluttercon/common/utils/misc.dart'; +import 'package:fluttercon/common/widgets/app_bar/session_filter.dart'; +import 'package:fluttercon/core/theme/theme_styles.dart'; +import 'package:fluttercon/l10n/l10n.dart'; +import 'package:wolt_modal_sheet/wolt_modal_sheet.dart'; + +/// Secondary toolbar for the Sessions screen: the compact/schedule view +/// toggle and the filter trigger. Lives below the app bar (as the first +/// header sliver in `SessionsScreen`'s `NestedScrollView`) rather than +/// crowding into the main app bar alongside the logo — see +/// docs/design/REBRAND-PLAN.md for context on why this moved. +class SessionViewToolbar extends StatelessWidget { + const SessionViewToolbar({ + required this.selectedScheduleIndex, + required this.onCompactTapped, + required this.onScheduleTapped, + super.key, + }); + + final int selectedScheduleIndex; + final VoidCallback onCompactTapped; + final VoidCallback onScheduleTapped; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final (_, colorScheme) = Misc.getTheme(context); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + borderRadius: Corners.pillBorder, + border: Border.all(color: colorScheme.primary), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _ViewToggleIcon( + assetPath: AppAssets.listAltIcon, + isSelected: selectedScheduleIndex == 0, + onTap: onCompactTapped, + ), + _ViewToggleIcon( + assetPath: AppAssets.viewAgendaIcon, + isSelected: selectedScheduleIndex == 1, + onTap: onScheduleTapped, + ), + ], + ), + ), + const Spacer(), + GestureDetector( + onTap: () => _showFilterSheet(context), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + borderRadius: Corners.pillBorder, + border: Border.all( + color: colorScheme.onSurface.withValues(alpha: 0.3), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AutoSizeText( + l10n.filter, + style: TextStyle( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + const SizedBox(width: 8), + SvgPicture.asset( + AppAssets.filterIcon, + colorFilter: ColorFilter.mode( + colorScheme.onSurface, + BlendMode.srcIn, + ), + height: 16, + ), + ], + ), + ), + ), + ], + ), + ); + } + + void _showFilterSheet(BuildContext context) { + final l10n = context.l10n; + final (_, colorScheme) = Misc.getTheme(context); + + WoltModalSheet.show( + context: context, + barrierDismissible: true, + pageListBuilder: (context) => [ + WoltModalSheetPage( + backgroundColor: colorScheme.secondaryContainer, + leadingNavBarWidget: Padding( + padding: const EdgeInsets.only(left: 16), + child: Row( + children: [ + SvgPicture.asset( + AppAssets.filterIcon, + colorFilter: ColorFilter.mode( + colorScheme.primary, + BlendMode.srcIn, + ), + ), + const SizedBox(width: 8), + AutoSizeText( + l10n.filter, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.primary, + ), + ), + ], + ), + ), + trailingNavBarWidget: GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Padding( + padding: const EdgeInsets.only(right: 16), + child: AutoSizeText( + l10n.cancel.toUpperCase(), + style: const TextStyle( + color: Colors.grey, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + child: const SessionFilter(), + ), + ], + modalTypeBuilder: (_) => WoltModalType.dialog(), + ); + } +} + +class _ViewToggleIcon extends StatelessWidget { + const _ViewToggleIcon({ + required this.assetPath, + required this.isSelected, + required this.onTap, + }); + + final String assetPath; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final (_, colorScheme) = Misc.getTheme(context); + + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: isSelected ? colorScheme.primary : Colors.transparent, + borderRadius: Corners.pillBorder, + ), + child: SvgPicture.asset( + assetPath, + colorFilter: ColorFilter.mode( + isSelected ? Colors.white : Colors.grey, + BlendMode.srcIn, + ), + height: 18, + ), + ), + ); + } +} diff --git a/lib/features/splash/splash_screen.dart b/lib/features/splash/splash_screen.dart index 6b03c9c7..9491540c 100644 --- a/lib/features/splash/splash_screen.dart +++ b/lib/features/splash/splash_screen.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:fluttercon/common/utils/constants/app_assets.dart'; -import 'package:fluttercon/common/utils/misc.dart'; import 'package:fluttercon/common/utils/router.dart'; import 'package:go_router/go_router.dart'; @@ -28,11 +27,9 @@ class _SplashScreenState extends State { @override Widget build(BuildContext context) { - final (_, colorScheme) = Misc.getTheme(context); - return Scaffold( - backgroundColor: colorScheme.surface, - body: const Center( - child: Image(image: AssetImage(AppAssets.imgDroidcon)), + return const Scaffold( + body: SafeArea( + child: Center(child: Image(image: AssetImage(AppAssets.imgDroidcon))), ), ); } diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 96869b94..7dcc7642 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -55,7 +55,7 @@ "@reset": {}, "about": "About", "@about": {}, - "aboutFluttercon": "Fluttercon Kenya is the first event of its kind in Africa, launching the Fluttercon conference on the continent. It will feature tech talks, workshops, and panels led by industry experts, Google Developer Experts, and seasoned Flutter specialists, all focusing on the latest advancements in Flutter and Dart technologies.\n\n Organized by the same team behind four successful editions of Droidcon Kenya, Fluttercon Kenya promises to maintain a high standard of excellence. With a history of hosting over 3,000 attendees and curating 200 sessions, the team brings unmatched expertise to this event.\n\n'Co-located with Droidcon Kenya, Africa's top Android developer conference, Fluttercon Kenya will run from November 6th to 8th, 2024. The joint event will gather hundreds of Flutter and Android developers, offering a packed program of tech talks, workshops, and panels, forming one of the continent's largest mobile developer gatherings.'", + "aboutFluttercon": "FlutterconKE returns for its 3rd edition on November 5th and 6th, 2026, at PrideInn Azure Hotel in Nairobi, bringing together Flutter and Dart experts, Google Developer Experts, and hundreds of Mobile developers from across the continent.\n\nCo-located with DroidconKE, FlutterconKE is part of next.app devCon, the global home of Droidcon, Fluttercon, and the wider mobile developer community.\n", "@aboutFluttercon": {}, "organisingTeam": "Organising Team", "@organisingTeam": {}, @@ -138,5 +138,39 @@ "noSessions": "No sessions found", "@noSessions": {}, "noPosts": "No posts found", - "@noPosts": {} -} \ No newline at end of file + "@noPosts": {}, + "noPostsMessage": "Check back later for updates from the team.", + "@noPostsMessage": {}, + "feedErrorTitle": "Couldn't load the feed", + "@feedErrorTitle": {}, + "sessionsErrorTitle": "Couldn't load sessions", + "@sessionsErrorTitle": {}, + "organisersErrorTitle": "Couldn't load the team", + "@organisersErrorTitle": {}, + "sponsorsErrorTitle": "Couldn't load sponsors", + "@sponsorsErrorTitle": {}, + "tryAgain": "Try again", + "@tryAgain": {}, + "confEssentialsTitle": "2026 Conf. Essentials", + "@confEssentialsTitle": {}, + "essentialPanels": "Panels", + "@essentialPanels": {}, + "essentialWorkshops": "Workshops", + "@essentialWorkshops": {}, + "essentialNetworking": "Networking", + "@essentialNetworking": {}, + "essentialExhibitions": "Exhibitions", + "@essentialExhibitions": {}, + "moreStacksAllMobile": "More Stacks.\nAll Mobile.", + "@moreStacksAllMobile": {}, + "confHighlightsTitle": "Conf. Highlights", + "@confHighlightsTitle": {}, + "beyondSessionsTitle": "Beyond the\nSessions", + "@beyondSessionsTitle": {}, + "beyondSessionsDescription": "Hands-on labs, engineering leader panels, and real dev talk. Master Agentic AI, level up your security game, and fast-track your career at the Unconference Track.", + "@beyondSessionsDescription": {}, + "developerDaysTitle": "Developer\nDays", + "@developerDaysTitle": {}, + "developerDaysDescription": "Level up across two days of hardcore Flutter and Dart sessions. From cross-platform performance to AI agents and tooling, get real-world insights from top-tier engineers shaping the ecosystem.", + "@developerDaysDescription": {} +} diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index b3c96c9d..28384ede 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -203,7 +203,7 @@ abstract class AppLocalizations { /// No description provided for @aboutFluttercon. /// /// In en, this message translates to: - /// **'Fluttercon Kenya is the first event of its kind in Africa, launching the Fluttercon conference on the continent. It will feature tech talks, workshops, and panels led by industry experts, Google Developer Experts, and seasoned Flutter specialists, all focusing on the latest advancements in Flutter and Dart technologies.\n\n Organized by the same team behind four successful editions of Droidcon Kenya, Fluttercon Kenya promises to maintain a high standard of excellence. With a history of hosting over 3,000 attendees and curating 200 sessions, the team brings unmatched expertise to this event.\n\n\'Co-located with Droidcon Kenya, Africa\'s top Android developer conference, Fluttercon Kenya will run from November 6th to 8th, 2024. The joint event will gather hundreds of Flutter and Android developers, offering a packed program of tech talks, workshops, and panels, forming one of the continent\'s largest mobile developer gatherings.\''** + /// **'FlutterconKE returns for its 3rd edition on November 5th and 6th, 2026, at PrideInn Azure Hotel in Nairobi, bringing together Flutter and Dart experts, Google Developer Experts, and hundreds of Mobile developers from across the continent.\n\nCo-located with DroidconKE, FlutterconKE is part of next.app devCon, the global home of Droidcon, Fluttercon, and the wider mobile developer community.\n'** String get aboutFluttercon; /// No description provided for @organisingTeam. @@ -433,6 +433,108 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'No posts found'** String get noPosts; + + /// No description provided for @noPostsMessage. + /// + /// In en, this message translates to: + /// **'Check back later for updates from the team.'** + String get noPostsMessage; + + /// No description provided for @feedErrorTitle. + /// + /// In en, this message translates to: + /// **'Couldn\'t load the feed'** + String get feedErrorTitle; + + /// No description provided for @sessionsErrorTitle. + /// + /// In en, this message translates to: + /// **'Couldn\'t load sessions'** + String get sessionsErrorTitle; + + /// No description provided for @organisersErrorTitle. + /// + /// In en, this message translates to: + /// **'Couldn\'t load the team'** + String get organisersErrorTitle; + + /// No description provided for @sponsorsErrorTitle. + /// + /// In en, this message translates to: + /// **'Couldn\'t load sponsors'** + String get sponsorsErrorTitle; + + /// No description provided for @tryAgain. + /// + /// In en, this message translates to: + /// **'Try again'** + String get tryAgain; + + /// No description provided for @confEssentialsTitle. + /// + /// In en, this message translates to: + /// **'2026 Conf. Essentials'** + String get confEssentialsTitle; + + /// No description provided for @essentialPanels. + /// + /// In en, this message translates to: + /// **'Panels'** + String get essentialPanels; + + /// No description provided for @essentialWorkshops. + /// + /// In en, this message translates to: + /// **'Workshops'** + String get essentialWorkshops; + + /// No description provided for @essentialNetworking. + /// + /// In en, this message translates to: + /// **'Networking'** + String get essentialNetworking; + + /// No description provided for @essentialExhibitions. + /// + /// In en, this message translates to: + /// **'Exhibitions'** + String get essentialExhibitions; + + /// No description provided for @moreStacksAllMobile. + /// + /// In en, this message translates to: + /// **'More Stacks.\nAll Mobile.'** + String get moreStacksAllMobile; + + /// No description provided for @confHighlightsTitle. + /// + /// In en, this message translates to: + /// **'Conf. Highlights'** + String get confHighlightsTitle; + + /// No description provided for @beyondSessionsTitle. + /// + /// In en, this message translates to: + /// **'Beyond the\nSessions'** + String get beyondSessionsTitle; + + /// No description provided for @beyondSessionsDescription. + /// + /// In en, this message translates to: + /// **'Hands-on labs, engineering leader panels, and real dev talk. Master Agentic AI, level up your security game, and fast-track your career at the Unconference Track.'** + String get beyondSessionsDescription; + + /// No description provided for @developerDaysTitle. + /// + /// In en, this message translates to: + /// **'Developer\nDays'** + String get developerDaysTitle; + + /// No description provided for @developerDaysDescription. + /// + /// In en, this message translates to: + /// **'Level up across two days of hardcore Flutter and Dart sessions. From cross-platform performance to AI agents and tooling, get real-world insights from top-tier engineers shaping the ecosystem.'** + String get developerDaysDescription; } class _AppLocalizationsDelegate diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index 220e052e..98761d70 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -69,7 +69,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get aboutFluttercon => - 'Fluttercon Kenya is the first event of its kind in Africa, launching the Fluttercon conference on the continent. It will feature tech talks, workshops, and panels led by industry experts, Google Developer Experts, and seasoned Flutter specialists, all focusing on the latest advancements in Flutter and Dart technologies.\n\n Organized by the same team behind four successful editions of Droidcon Kenya, Fluttercon Kenya promises to maintain a high standard of excellence. With a history of hosting over 3,000 attendees and curating 200 sessions, the team brings unmatched expertise to this event.\n\n\'Co-located with Droidcon Kenya, Africa\'s top Android developer conference, Fluttercon Kenya will run from November 6th to 8th, 2024. The joint event will gather hundreds of Flutter and Android developers, offering a packed program of tech talks, workshops, and panels, forming one of the continent\'s largest mobile developer gatherings.\''; + 'FlutterconKE returns for its 3rd edition on November 5th and 6th, 2026, at PrideInn Azure Hotel in Nairobi, bringing together Flutter and Dart experts, Google Developer Experts, and hundreds of Mobile developers from across the continent.\n\nCo-located with DroidconKE, FlutterconKE is part of next.app devCon, the global home of Droidcon, Fluttercon, and the wider mobile developer community.\n'; @override String get organisingTeam => 'Organising Team'; @@ -189,4 +189,57 @@ class AppLocalizationsEn extends AppLocalizations { @override String get noPosts => 'No posts found'; + + @override + String get noPostsMessage => 'Check back later for updates from the team.'; + + @override + String get feedErrorTitle => 'Couldn\'t load the feed'; + + @override + String get sessionsErrorTitle => 'Couldn\'t load sessions'; + + @override + String get organisersErrorTitle => 'Couldn\'t load the team'; + + @override + String get sponsorsErrorTitle => 'Couldn\'t load sponsors'; + + @override + String get tryAgain => 'Try again'; + + @override + String get confEssentialsTitle => '2026 Conf. Essentials'; + + @override + String get essentialPanels => 'Panels'; + + @override + String get essentialWorkshops => 'Workshops'; + + @override + String get essentialNetworking => 'Networking'; + + @override + String get essentialExhibitions => 'Exhibitions'; + + @override + String get moreStacksAllMobile => 'More Stacks.\nAll Mobile.'; + + @override + String get confHighlightsTitle => 'Conf. Highlights'; + + @override + String get beyondSessionsTitle => 'Beyond the\nSessions'; + + @override + String get beyondSessionsDescription => + 'Hands-on labs, engineering leader panels, and real dev talk. Master Agentic AI, level up your security game, and fast-track your career at the Unconference Track.'; + + @override + String get developerDaysTitle => 'Developer\nDays'; + + @override + String get developerDaysDescription => + 'Level up across two days of hardcore Flutter and Dart sessions. From cross-platform performance to AI agents and tooling, get real-world insights from top-tier engineers shaping the ecosystem.'; } diff --git a/lib/main_development.dart b/lib/main_development.dart index 4d64dff4..313309d1 100644 --- a/lib/main_development.dart +++ b/lib/main_development.dart @@ -17,7 +17,7 @@ Future main() async { baseDomain: 'api.droidcon.co.ke', urlScheme: 'https', hiveBox: 'fluttercon-dev', - eventSlug: 'flutterconke-2025-171', + eventSlug: 'fluttercon-ke-2026-222', organiserSlug: 'flutterconke-571', ), ); diff --git a/lib/main_production.dart b/lib/main_production.dart index 0303e406..aa9853dd 100644 --- a/lib/main_production.dart +++ b/lib/main_production.dart @@ -17,7 +17,7 @@ Future main() async { baseDomain: 'api.droidcon.co.ke', urlScheme: 'https', hiveBox: 'fluttercon-prod', - eventSlug: 'flutterconke-2025-171', + eventSlug: 'fluttercon-ke-2026-222', organiserSlug: 'flutterconke-571', ), ); diff --git a/lib/main_staging.dart b/lib/main_staging.dart index 40d71162..c99fd463 100644 --- a/lib/main_staging.dart +++ b/lib/main_staging.dart @@ -17,7 +17,7 @@ Future main() async { baseDomain: 'api.droidcon.co.ke', urlScheme: 'https', hiveBox: 'fluttercon-stg', - eventSlug: 'flutterconke-2025-171', + eventSlug: 'fluttercon-ke-2026-222', organiserSlug: 'flutterconke-571', ), ); diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 668b3211..737a2126 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -14,7 +14,6 @@ import firebase_remote_config import flutter_local_notifications import google_sign_in_ios import isar_community_flutter_libs -import path_provider_foundation import share_plus import sqflite_darwin import url_launcher_macos @@ -24,12 +23,11 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FirebaseAnalyticsPlugin")) FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) - FLTFirebaseCrashlyticsPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCrashlyticsPlugin")) + FirebaseCrashlyticsPlugin.register(with: registry.registrar(forPlugin: "FirebaseCrashlyticsPlugin")) FirebaseRemoteConfigPlugin.register(with: registry.registrar(forPlugin: "FirebaseRemoteConfigPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) IsarFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "IsarFlutterLibsPlugin")) - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 7834be72..25c6ad61 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,26 +5,26 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f + sha256: "3b19a47f6ea7c2632760777c78174f47f6aec1e05f0cd611380d4593b8af1dbc" url: "https://pub.dev" source: hosted - version: "85.0.0" + version: "96.0.0" _flutterfire_internals: dependency: transitive description: name: _flutterfire_internals - sha256: "23d16f00a2da8ffa997c782453c73867b0609bd90435195671a54de38a3566df" + sha256: f6966125633a34f82d9be2ee895b755cff3554087b1d00a9eb884e5947f4bca9 url: "https://pub.dev" source: hosted - version: "1.3.62" + version: "1.3.77" analyzer: dependency: transitive description: name: analyzer - sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d" + sha256: "0c516bc4ad36a1a75759e54d5047cb9d15cded4459df01aa35a0b5ec7db2c2a0" url: "https://pub.dev" source: hosted - version: "7.7.1" + version: "10.2.0" ansicolor: dependency: transitive description: @@ -37,18 +37,18 @@ packages: dependency: "direct main" description: name: appinio_social_share - sha256: df12236ed9dd202b6cd7095f7f9c739416726ec5a53b923ea535d745fb5dc938 + sha256: "319bbc095a94b33b2899ac61e6207cc72fdd70c4ce43121a2d6e10bb8b7d566e" url: "https://pub.dev" source: hosted - version: "0.3.2" + version: "0.3.3" archive: dependency: transitive description: name: archive - sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19 url: "https://pub.dev" source: hosted - version: "4.0.7" + version: "4.2.0" args: dependency: transitive description: @@ -61,10 +61,10 @@ packages: dependency: transitive description: name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 url: "https://pub.dev" source: hosted - version: "2.13.0" + version: "2.13.1" auth_buttons: dependency: "direct main" description: @@ -93,10 +93,10 @@ packages: dependency: "direct main" description: name: bloc - sha256: a2cebb899f91d36eeeaa55c7b20b5915db5a9df1b8fd4a3c9c825e22e474537d + sha256: e03b235924e4f509c27b5d6b2f949200e0a91149a9818b4f65eeb56662b75413 url: "https://pub.dev" source: hosted - version: "9.1.0" + version: "9.2.1" boolean_selector: dependency: transitive description: @@ -109,50 +109,34 @@ packages: dependency: transitive description: name: build - sha256: ce76b1d48875e3233fde17717c23d1f60a91cc631597e49a400c89b475395b1d + sha256: "45d14a0fb23e018d8287c32fc98d726ce466b231928ed9b9200f29bd3ccd39ae" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "4.0.7" build_config: dependency: transitive description: name: build_config - sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187" + sha256: d466ed2dc9c6cd1d169948879b84ee061eb5e22c64a7c6089879c6296d272a8d url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.3" build_daemon: dependency: transitive description: name: build_daemon - sha256: "8e928697a82be082206edb0b9c99c5a4ad6bc31c9e9b8b2f291ae65cd4a25daa" - url: "https://pub.dev" - source: hosted - version: "4.0.4" - build_resolvers: - dependency: transitive - description: - name: build_resolvers - sha256: d1d57f7807debd7349b4726a19fd32ec8bc177c71ad0febf91a20f84cd2d4b46 + sha256: e1d40ef3f7934986d5da2271b1ba07794921ce263e44d622fb6c406d76589e33 url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "4.1.6" build_runner: dependency: "direct dev" description: name: build_runner - sha256: b24597fceb695969d47025c958f3837f9f0122e237c6a22cb082a5ac66c3ca30 - url: "https://pub.dev" - source: hosted - version: "2.7.1" - build_runner_core: - dependency: transitive - description: - name: build_runner_core - sha256: "066dda7f73d8eb48ba630a55acb50c4a84a2e6b453b1cb4567f581729e794f7b" + sha256: "5367e521935b102bdf1e735d2aab461e36b2edca6517662d088dd04cc39f8d16" url: "https://pub.dev" source: hosted - version: "9.3.1" + version: "2.15.1" build_version: dependency: "direct main" description: @@ -173,10 +157,10 @@ packages: dependency: transitive description: name: built_value - sha256: "082001b5c3dc495d4a42f1d5789990505df20d8547d42507c29050af6933ee27" + sha256: f87ea98192116f7093cb214551ce1929caae0681fdba282b3d8b4462adee7bb7 url: "https://pub.dev" source: hosted - version: "8.10.1" + version: "8.13.0" cached_network_image: dependency: "direct main" description: @@ -205,18 +189,18 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: name: checked_yaml - sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "2.0.4" cli_util: dependency: transitive description: @@ -229,18 +213,26 @@ packages: dependency: transitive description: name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e + url: "https://pub.dev" + source: hosted + version: "1.1.3" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: cfd4f5f575a49c5f10ca856e9846073f1e6c3ee94912377eea5f6cefc5272941 url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "2.0.0" code_builder: dependency: transitive description: name: code_builder - sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" + sha256: aa5932e94c6c39c2f9ec4e5e06dfdd11a9430a61f6c41b6ba75b28ce0c481baf url: "https://pub.dev" source: hosted - version: "4.10.1" + version: "4.12.0" collection: dependency: "direct main" description: @@ -261,18 +253,18 @@ packages: dependency: transitive description: name: cross_file - sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 url: "https://pub.dev" source: hosted - version: "0.3.4+2" + version: "0.3.5+5" crypto: dependency: transitive description: name: crypto - sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf url: "https://pub.dev" source: hosted - version: "3.0.6" + version: "3.0.7" csslib: dependency: transitive description: @@ -285,18 +277,18 @@ packages: dependency: "direct main" description: name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" url: "https://pub.dev" source: hosted - version: "1.0.8" + version: "1.0.9" dart_style: dependency: transitive description: name: dart_style - sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" + sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2" url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.1.7" dartx: dependency: transitive description: @@ -309,26 +301,26 @@ packages: dependency: transitive description: name: dbus - sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383 url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.15" dio: dependency: "direct main" description: name: dio - sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 + sha256: "852ec3b48cc431ac04fff978413c541502b67ffc3e26921e74e3d994694192c1" url: "https://pub.dev" source: hosted - version: "5.9.0" + version: "5.11.1" dio_web_adapter: dependency: transitive description: name: dio_web_adapter - sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + sha256: "3a1b2cd7be71086f38504956e3ebcd2837288d231ff454bafa78021244102bfc" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.2.2" fake_async: dependency: transitive description: @@ -341,10 +333,10 @@ packages: dependency: transitive description: name: ffi - sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" file: dependency: transitive description: @@ -357,114 +349,114 @@ packages: dependency: "direct main" description: name: firebase_analytics - sha256: fce78440ab7b95563054039aac5e342088efed9dc009ac6f81d5cac07155d509 + sha256: "0bf812fe5fecf395c07b673f40d951929f631749bd7513a31b8bfafa182d9a06" url: "https://pub.dev" source: hosted - version: "12.0.2" + version: "12.5.0" firebase_analytics_platform_interface: dependency: transitive description: name: firebase_analytics_platform_interface - sha256: "75bdcd2d2635c4cdcd7ec13727527751ddf2f9933e5bf1264a2387920246f3c5" + sha256: "5c3ab4b681c837afd71f0abf77c7485f51c7b645d4ae77b96f9ed54779ff4126" url: "https://pub.dev" source: hosted - version: "5.0.2" + version: "6.0.7" firebase_analytics_web: dependency: transitive description: name: firebase_analytics_web - sha256: ed5767695b131cdd425ee6d49934dca80689d9df40609c0d0aa8907ee6f0f785 + sha256: "09eb4d0ce4c9a16efbc277ac709a90eb197d5512592215a73c6a302ab1ab6c3a" url: "https://pub.dev" source: hosted - version: "0.6.0+2" + version: "0.6.1+13" firebase_auth: dependency: "direct main" description: name: firebase_auth - sha256: "735f857c9363376eeb585e7ba57e67e5f495202cd3f609902b8769795fd823bc" + sha256: "89bf082422d09cb19d32450c6693c135ebadb2fef4852b2ad0daaa9820136893" url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "6.6.1" firebase_auth_platform_interface: dependency: transitive description: name: firebase_auth_platform_interface - sha256: "5badda0ea5048ffbb1726169cf5530539490de8055c3bd43f4f9cd5fcef8e556" + sha256: f693f59a1f11f2769cc9c2362e6032b5a95bc63d0b4a8a25ccd5190ea5899693 url: "https://pub.dev" source: hosted - version: "8.1.2" + version: "9.0.7" firebase_auth_web: dependency: transitive description: name: firebase_auth_web - sha256: "07c889d2c56e648ed30225e819801d7e45542747a658d9c385520de35d312dec" + sha256: cfb98837959f3677696c997856cc8dedbb3e52367397ec80437b85b735ad6c17 url: "https://pub.dev" source: hosted - version: "6.0.3" + version: "6.2.7" firebase_core: dependency: "direct main" description: name: firebase_core - sha256: "4dd96f05015c0dcceaa47711394c32971aee70169625d5e2477e7676c01ce0ee" + sha256: "2343710d8a164e3157a5e2fbb00663503c669be4aa06185311df48626760fdf1" url: "https://pub.dev" source: hosted - version: "4.1.1" + version: "4.14.0" firebase_core_platform_interface: dependency: transitive description: name: firebase_core_platform_interface - sha256: "5873a370f0d232918e23a5a6137dbe4c2c47cf017301f4ea02d9d636e52f60f0" + sha256: "9bfbc85faca09346471ac56fbcee00757ebcfd85887c6f602764eff0001902d8" url: "https://pub.dev" source: hosted - version: "6.0.1" + version: "8.1.1" firebase_core_web: dependency: transitive description: name: firebase_core_web - sha256: "61a51037312dac781f713308903bb7a1762a7f92f7bc286a3a0947fb2a713b82" + sha256: de1e678209a0c974d8aa4cff39f17d61d2dc263aa64993168360f9be4efb8e91 url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.11.0" firebase_crashlytics: dependency: "direct main" description: name: firebase_crashlytics - sha256: a636096df0d2a4bc72397bfc669a4fffc8896016a58de1a6f45a49d9ba064f94 + sha256: aca14d94150d50e3f9e0f5090008fa98232d924bc780689481056521d9dc25c9 url: "https://pub.dev" source: hosted - version: "5.0.2" + version: "5.3.0" firebase_crashlytics_platform_interface: dependency: transitive description: name: firebase_crashlytics_platform_interface - sha256: "1ccad077a6fc7bace97d8eace263f42e66dc23a23a839de864a4f10ac4a7c264" + sha256: "8270dcdb1800db3e5f888ce30a2d5e8f92c379c461f022297c83b2c794aa1792" url: "https://pub.dev" source: hosted - version: "3.8.13" + version: "3.9.0" firebase_remote_config: dependency: "direct main" description: name: firebase_remote_config - sha256: "6406d65d88d41891d201095d1a6bff8ecabe91f3097090db00900d53826f2f9f" + sha256: b17051e0d8ebd26ec9745a7a6cb8f0762b6e3281d23126dc9ace728e499f3bfd url: "https://pub.dev" source: hosted - version: "6.0.2" + version: "6.6.0" firebase_remote_config_platform_interface: dependency: transitive description: name: firebase_remote_config_platform_interface - sha256: "60ec31cc11bcf2f416e86dd77521784b7dc4be1b82118ca10a1ce9a29fa85e09" + sha256: dc5a9713031f2ca744cb266d96a58609e929db19f3d91a1dd6bb8f8a0d77acad url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "3.0.7" firebase_remote_config_web: dependency: transitive description: name: firebase_remote_config_web - sha256: "864791054997d63987e0fbb2d7b175ab04e41060a9315b7eeddba2974603247c" + sha256: "500abf695c370c76a486ad7480a6df7caf55d2d35ccc5c66b9fc9c1c47f2bc14" url: "https://pub.dev" source: hosted - version: "1.8.12" + version: "1.10.14" fixnum: dependency: transitive description: @@ -490,10 +482,10 @@ packages: dependency: transitive description: name: flutter_cache_manager - sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + sha256: "1de7849213b4c73c85aca7e0ac687a9a5d82ccdb594366b9dcc26cb6a2189cd2" url: "https://pub.dev" source: hosted - version: "3.4.1" + version: "3.4.2" flutter_launcher_icons: dependency: "direct dev" description: @@ -506,10 +498,10 @@ packages: dependency: "direct main" description: name: flutter_local_notifications - sha256: "7ed76be64e8a7d01dfdf250b8434618e2a028c9dfa2a3c41dc9b531d4b3fc8a5" + sha256: "19ffb0a8bb7407875555e5e98d7343a633bb73707bae6c6a5f37c90014077875" url: "https://pub.dev" source: hosted - version: "19.4.2" + version: "19.5.0" flutter_local_notifications_linux: dependency: transitive description: @@ -543,18 +535,18 @@ packages: dependency: "direct dev" description: name: flutter_native_splash - sha256: "8321a6d11a8d13977fa780c89de8d257cce3d841eecfb7a4cadffcc4f12d82dc" + sha256: "9db4b80b044e9af17cc4b1272137fc7ace0054d879ef8210a76adc34aaf4cdff" url: "https://pub.dev" source: hosted - version: "2.4.6" + version: "2.4.8" flutter_svg: dependency: "direct main" description: name: flutter_svg - sha256: b9c2ad5872518a27507ab432d1fb97e8813b05f0fc693f9d40fad06d073e0678 + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.3.0" flutter_test: dependency: "direct dev" description: flutter @@ -569,10 +561,10 @@ packages: dependency: "direct dev" description: name: freezed - sha256: "13065f10e135263a4f5a4391b79a8efc5fb8106f8dd555a9e49b750b45393d77" + sha256: f23ea33b3863f119b58ed1b586e881a46bd28715ddcc4dbc33104524e3434131 url: "https://pub.dev" source: hosted - version: "3.2.3" + version: "3.2.5" freezed_annotation: dependency: "direct main" description: @@ -593,34 +585,34 @@ packages: dependency: "direct main" description: name: get_it - sha256: a4292e7cf67193f8e7c1258203104eb2a51ec8b3a04baa14695f4064c144297b + sha256: ae78de7c3f2304b8d81f2bb6e320833e5e81de942188542328f074978cc0efa9 url: "https://pub.dev" source: hosted - version: "8.2.0" + version: "8.3.0" glob: dependency: transitive description: name: glob - sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.2.0" go_router: dependency: "direct main" description: name: go_router - sha256: c752e2d08d088bf83742cb05bf83003f3e9d276ff1519b5c92f9d5e60e5ddd23 + sha256: d8f590a69729f719177ea68eb1e598295e8dbc41bbc247fed78b2c8a25660d7c url: "https://pub.dev" source: hosted - version: "16.2.4" + version: "16.3.0" google_fonts: dependency: "direct main" description: name: google_fonts - sha256: "517b20870220c48752eafa0ba1a797a092fb22df0d89535fd9991e86ee2cdd9c" + sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 url: "https://pub.dev" source: hosted - version: "6.3.2" + version: "6.3.3" google_identity_services_web: dependency: transitive description: @@ -641,18 +633,18 @@ packages: dependency: transitive description: name: google_sign_in_android - sha256: "799165f4c0621ed233bccdded4c2e92739bc1fe73e970163b2f7493b301adad3" + sha256: c403315d87aba1f815a0a401093f97808cab6cb2bdd11ca431ff8587fd7f1c00 url: "https://pub.dev" source: hosted - version: "7.2.1" + version: "7.2.17" google_sign_in_ios: dependency: transitive description: name: google_sign_in_ios - sha256: d9d80f953a244a099a40df1ff6aadc10ee375e6a098bbd5d55be332ce26db18c + sha256: "50ab85d3a732227807bb871a5df0dfaa09005f24b00b6f775f500c7b62b12d7e" url: "https://pub.dev" source: hosted - version: "6.2.1" + version: "6.3.3" google_sign_in_platform_interface: dependency: transitive description: @@ -665,10 +657,10 @@ packages: dependency: transitive description: name: google_sign_in_web - sha256: "2fc1f941e6443b2d6984f4056a727a3eaeab15d8ee99ba7125d79029be75a1da" + sha256: d473003eeca892f96a01a64fc803378be765071cb0c265ee872c7f8683245d14 url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.3" graphs: dependency: transitive description: @@ -681,42 +673,58 @@ packages: dependency: "direct main" description: name: hive_ce - sha256: d678b1b2e315c18cd7ed8fd79eda25d70a1f3852d6988bfe5461cffe260c60aa + sha256: "8e9980e68643afb1e765d3af32b47996552a64e190d03faf622cea07c1294418" url: "https://pub.dev" source: hosted - version: "2.14.0" + version: "2.19.3" hive_ce_flutter: dependency: "direct main" description: name: hive_ce_flutter - sha256: f5bd57fda84402bca7557fedb8c629c96c8ea10fab4a542968d7b60864ca02cc + sha256: "2677e95a333ff15af43ccd06af7eb7abbf1a4f154ea071997f3de4346cae913a" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.3.4" hive_ce_generator: dependency: "direct dev" description: name: hive_ce_generator - sha256: a169feeff2da9cc2c417ce5ae9bcebf7c8a95d7a700492b276909016ad70a786 + sha256: c9a7cda57823ffec35846c051637ddcd105eafa253e7395d9c8c0ed5e87fbb72 + url: "https://pub.dev" + source: hosted + version: "1.11.1" + hooks: + dependency: transitive + description: + name: hooks + sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f + url: "https://pub.dev" + source: hosted + version: "2.2.0" + hotreloader: + dependency: transitive + description: + name: hotreloader + sha256: "66871df468fc24eee81f1a0a7cb98acc104716f9b7376d355437b48d633c4ebf" url: "https://pub.dev" source: hosted - version: "1.9.3" + version: "4.4.0" html: dependency: transitive description: name: html - sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + sha256: "43b67b8f43321ab066817dfac5619596c98bb1b61624e77203bb4351785f9699" url: "https://pub.dev" source: hosted - version: "0.15.6" + version: "0.15.7" http: dependency: transitive description: name: http - sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.6.0" http_multi_server: dependency: transitive description: @@ -737,82 +745,106 @@ packages: dependency: "direct dev" description: name: icons_launcher - sha256: "6317d56a73ee528f1dd570d7cd7be120ce58014e0fe635d141ada3d88782f58d" + sha256: b42b2f9b10e58d6a973f71293f00a1f0572595e5e50d676c53048e464f78cb7d url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.1.0" image: dependency: transitive description: name: image - sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e" url: "https://pub.dev" source: hosted - version: "4.5.4" + version: "4.9.2" injectable: dependency: "direct main" description: name: injectable - sha256: f71ef28f5ed30603ab9be004425fd5cd4a473806f5de269d35deb33b61917534 + sha256: "32b36a9d87f18662bee0b1951b81f47a01f2bf28cd6ea94f60bc5453c7bf598c" url: "https://pub.dev" source: hosted - version: "2.5.2" + version: "2.7.1+4" injectable_generator: dependency: "direct dev" description: name: injectable_generator - sha256: "09c55dba52b53d17411b90134a6751270b8930abd2529e2637d700fc99b0d5b5" + sha256: fcc0d1edcef2c863dec846b0dec27cb2390d9e9b62e61da5cfc2b9a06b9533c2 url: "https://pub.dev" source: hosted - version: "2.8.1" + version: "2.12.1" intl: dependency: "direct main" description: name: intl - sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" url: "https://pub.dev" source: hosted - version: "0.20.2" + version: "0.20.3" io: dependency: transitive description: name: io - sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + sha256: "2635216ca6a737e60de577ffa1a48a0bec76ca8a62917cfc1bb88c14c570646f" url: "https://pub.dev" source: hosted - version: "1.0.5" + version: "1.1.0" isar_community: dependency: "direct main" description: name: isar_community - sha256: "28f59e54636c45ba0bb1b3b7f2656f1c50325f740cea6efcd101900be3fba546" + sha256: "683fd093956eac3a660776afb70d65d50a3391b3587b40b89e3d12586b8cc47f" url: "https://pub.dev" source: hosted - version: "3.3.0-dev.3" + version: "3.3.2" isar_community_flutter_libs: dependency: "direct main" description: name: isar_community_flutter_libs - sha256: c2934fe755bb3181cb67602fd5df0d080b3d3eb52799f98623aa4fc5acbea010 + sha256: c44340fa38c81ef16d924202d443bbe799cde4826be9a31a9dc92ee612e1966f url: "https://pub.dev" source: hosted - version: "3.3.0-dev.3" + version: "3.3.2" isar_community_generator: dependency: "direct dev" description: name: isar_community_generator - sha256: "1d760de09ffbc837ad65476f14b6ad17381f0fb485e01bf77d76d825ef734bec" + sha256: fd5a2a5df2aac2fccba82425005bf47fd2a48f34190536d0c9fd732dc7303fb3 url: "https://pub.dev" source: hosted - version: "3.3.0-dev.3" + version: "3.3.2" isolate_channel: dependency: transitive description: name: isolate_channel - sha256: f3d36f783b301e6b312c3450eeb2656b0e7d1db81331af2a151d9083a3f6b18d + sha256: a9d3d620695bc984244dafae00b95e4319d6974b2d77f4b9e1eb4f2efe099094 url: "https://pub.dev" source: hosted - version: "0.2.2+1" + version: "0.6.1" + jni: + dependency: transitive + description: + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: b2310cdd4c18c65c081ab141a41efa94aa26c65431803703ece51996f174f351 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" js: dependency: transitive description: @@ -825,18 +857,18 @@ packages: dependency: "direct main" description: name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" url: "https://pub.dev" source: hosted - version: "4.9.0" + version: "4.12.0" json_serializable: dependency: "direct dev" description: name: json_serializable - sha256: "33a040668b31b320aafa4822b7b1e177e163fc3c1e835c6750319d4ab23aa6fe" + sha256: e45aefa0324f08c683caafbb94b72837aa6193c61822799c916e45f4a263113d url: "https://pub.dev" source: hosted - version: "6.11.1" + version: "6.14.1" leak_tracker: dependency: transitive description: @@ -861,14 +893,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + lean_builder: + dependency: transitive + description: + name: lean_builder + sha256: c16e95ddf7b2d49dd551357b7212fe2ce9f13ec7ad1b1e660c157184031e96c0 + url: "https://pub.dev" + source: hosted + version: "0.1.10" logger: dependency: "direct main" description: name: logger - sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 + sha256: "2a0dc097e7b01d942475bdd552356db2d0f768b05540bd4b2b53f1840f2239a7" url: "https://pub.dev" source: hosted - version: "2.6.2" + version: "2.8.0" logging: dependency: transitive description: @@ -881,42 +921,42 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.20" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.19.0" mime: dependency: transitive description: name: mime - sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.0" mocktail: dependency: "direct dev" description: name: mocktail - sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" + sha256: "5e1bf53cc7baa8062a33b84424deb61513858ea05c601b8509e683815b5914aa" url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.0.5" nested: dependency: transitive description: @@ -925,6 +965,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: ad56fd53a78ff6b1472fa59ff2a4e8b8ccabafc586fc263a1dfad0b99b5553e3 + url: "https://pub.dev" + source: hosted + version: "9.6.0" octo_image: dependency: transitive description: @@ -961,42 +1009,42 @@ packages: dependency: "direct main" description: name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.1.6" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" url: "https://pub.dev" source: hosted - version: "2.2.17" + version: "2.3.1" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.6.0" path_provider_linux: dependency: transitive description: name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.2" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" path_provider_windows: dependency: transitive description: @@ -1009,18 +1057,18 @@ packages: dependency: transitive description: name: petitparser - sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "7.0.2" platform: dependency: transitive description: name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec url: "https://pub.dev" source: hosted - version: "3.1.6" + version: "3.2.0" plugin_platform_interface: dependency: transitive description: @@ -1033,18 +1081,18 @@ packages: dependency: transitive description: name: pool - sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + sha256: "4177f68c237ea2128d1bee66ac17b2ce05ba3dbaafcbdd54c5d40a39d0b6b11c" url: "https://pub.dev" source: hosted - version: "1.5.1" + version: "1.5.3" posix: dependency: transitive description: name: posix - sha256: f0d7856b6ca1887cfa6d1d394056a296ae33489db914e365e2044fdada449e62 + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e url: "https://pub.dev" source: hosted - version: "6.0.2" + version: "6.5.2" pretty_dio_logger: dependency: "direct main" description: @@ -1057,26 +1105,26 @@ packages: dependency: transitive description: name: provider - sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84" + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" url: "https://pub.dev" source: hosted - version: "6.1.5" + version: "6.1.5+1" pub_semver: dependency: transitive description: name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.2.1" pubspec_parse: dependency: transitive description: name: pubspec_parse - sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + sha256: c38b81cbf34450b67e0265d73433569d12e34782e30ed769c9cc99c9d5f2e796 url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "1.6.0" recase: dependency: transitive description: @@ -1085,6 +1133,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "1cb8564af8d43b464294411db9217f5ec04891c6f22ee2c32d73ae05e88a6bd2" + url: "https://pub.dev" + source: hosted + version: "1.1.1" rxdart: dependency: transitive description: @@ -1097,10 +1153,10 @@ packages: dependency: "direct main" description: name: share_plus - sha256: "3424e9d5c22fd7f7590254ba09465febd6f8827c8b19a44350de4ac31d92d3a6" + sha256: "223873d106614442ea6f20db5a038685cc5b32a2fba81cdecaefbbae0523f7fa" url: "https://pub.dev" source: hosted - version: "12.0.0" + version: "12.0.2" share_plus_platform_interface: dependency: transitive description: @@ -1142,82 +1198,74 @@ packages: dependency: transitive description: name: source_gen - sha256: "7b19d6ba131c6eb98bfcbf8d56c1a7002eba438af2e7ae6f8398b2b0f4f381e3" + sha256: a603f1fb984a7391ae5978d1b92bfaaa08b350dca5c825256f925818f7943bf5 url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "4.2.4" source_helper: dependency: transitive description: name: source_helper - sha256: "6a3c6cc82073a8797f8c4dc4572146114a39652851c157db37e964d9c7038723" + sha256: "5e6f216fdf6376c9f3852381ae037499797a3385377d388b011dac98d303c67c" url: "https://pub.dev" source: hosted - version: "1.3.8" + version: "1.3.13" source_span: dependency: transitive description: name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.dev" source: hosted - version: "1.10.1" - sprintf: - dependency: transitive - description: - name: sprintf - sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" - url: "https://pub.dev" - source: hosted - version: "7.0.0" + version: "1.10.2" sqflite: dependency: transitive description: name: sqflite - sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + sha256: "54ad23e2a79c846b596b6c56cdd9c867ea58c288258402316b602f78df0cbfe0" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.4" sqflite_android: dependency: transitive description: name: sqflite_android - sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b" + sha256: "805b27c6fa10d8883253795e644818fbd5c87ce665459db84fbe71326cbb5421" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.4" sqflite_common: dependency: transitive description: name: sqflite_common - sha256: "84731e8bfd8303a3389903e01fb2141b6e59b5973cacbb0929021df08dddbe8b" + sha256: b3ebf7b30c8ae279615f146dfc29ea6ba097299294193107c85a7531ec2346d9 url: "https://pub.dev" source: hosted - version: "2.5.5" + version: "2.5.12" sqflite_darwin: dependency: transitive description: name: sqflite_darwin - sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + sha256: dbdda396f975f74d611ffd3e6fb280ee3ee4cd2319b89278533f427e0140cf0b url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.4" sqflite_platform_interface: dependency: transitive description: name: sqflite_platform_interface - sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + sha256: c9177b2f3150ed0647b90a94307c28452fe0a5401863044216aed83addd6c7c5 url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.2" stack_trace: dependency: transitive description: name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" url: "https://pub.dev" source: hosted - version: "1.12.1" + version: "1.12.2" stream_channel: dependency: transitive description: @@ -1230,10 +1278,10 @@ packages: dependency: transitive description: name: stream_transform - sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + sha256: a00e5f18bffc764f923e7dec1038527f7fe7a1791361a7117f0358193f13d53a url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" string_scanner: dependency: transitive description: @@ -1246,10 +1294,10 @@ packages: dependency: transitive description: name: synchronized - sha256: "0669c70faae6270521ee4f05bffd2919892d42d1276e6c495be80174b6bc0ef6" + sha256: "397b146f97613b83d84bdccb439bc8d0f3aebb6c1d14a9641e6f24201c490b2d" url: "https://pub.dev" source: hosted - version: "3.3.1" + version: "3.4.2" term_glyph: dependency: transitive description: @@ -1262,18 +1310,18 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.12" time: dependency: transitive description: name: time - sha256: "370572cf5d1e58adcb3e354c47515da3f7469dac3a95b447117e728e7be6f461" + sha256: "46187cf30bffdab28c56be9a63861b36e4ab7347bf403297595d6a97e10c789f" url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.1.6" timeago: dependency: "direct main" description: @@ -1298,14 +1346,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.10.1" - timing: - dependency: transitive - description: - name: timing - sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" - url: "https://pub.dev" - source: hosted - version: "1.0.2" typed_data: dependency: transitive description: @@ -1318,10 +1358,10 @@ packages: dependency: transitive description: name: universal_io - sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" + sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.3.1" url_launcher: dependency: "direct main" description: @@ -1334,34 +1374,34 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "8582d7f6fe14d2652b4c45c9b6c14c0b678c2af2d083a11b604caeba51930d79" + sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610" url: "https://pub.dev" source: hosted - version: "6.3.16" + version: "6.3.33" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb" + sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a" url: "https://pub.dev" source: hosted - version: "6.3.3" + version: "6.4.2" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0" url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.2.3" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" + sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201" url: "https://pub.dev" source: hosted - version: "3.2.2" + version: "3.2.6" url_launcher_platform_interface: dependency: transitive description: @@ -1374,34 +1414,34 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.3" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.6" uuid: dependency: transitive description: name: uuid - sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" url: "https://pub.dev" source: hosted - version: "4.5.1" + version: "4.6.0" vector_graphics: dependency: transitive description: name: vector_graphics - sha256: "44cc7104ff32563122a929e4620cf3efd584194eec6d1d913eb5ba593dbcf6de" + sha256: "9d0e3b9cb16542ad660daee871e726a10d13a93b7b5391677c3160e8f5e83935" url: "https://pub.dev" source: hosted - version: "1.1.18" + version: "1.2.3" vector_graphics_codec: dependency: transitive description: @@ -1414,42 +1454,42 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: "557a315b7d2a6dbb0aaaff84d857967ce6bdc96a63dc6ee2a57ce5a6ee5d3331" + sha256: "4dca4feb77dc3ec7f6e27e49c53241eb8217f55e4f9b12599a27f8903bca5682" url: "https://pub.dev" source: hosted - version: "1.1.17" + version: "1.3.0" vector_math: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.2" very_good_analysis: dependency: "direct dev" description: name: very_good_analysis - sha256: "96245839dbcc45dfab1af5fa551603b5c7a282028a64746c19c547d21a7f1e3a" + sha256: "481af67ab5877af20325251dc215a4ebac7666a1c8cf09198ffd457bc612b33d" url: "https://pub.dev" source: hosted - version: "10.0.0" + version: "10.3.0" vm_service: dependency: transitive description: name: vm_service - sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" url: "https://pub.dev" source: hosted - version: "15.0.0" + version: "15.3.0" watcher: dependency: transitive description: name: watcher - sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.2.1" web: dependency: transitive description: @@ -1478,10 +1518,10 @@ packages: dependency: transitive description: name: win32 - sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba" + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e url: "https://pub.dev" source: hosted - version: "5.13.0" + version: "5.15.0" wolt_modal_sheet: dependency: "direct main" description: @@ -1502,10 +1542,10 @@ packages: dependency: transitive description: name: xml - sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" url: "https://pub.dev" source: hosted - version: "6.5.0" + version: "6.6.1" xxh3: dependency: transitive description: @@ -1518,10 +1558,10 @@ packages: dependency: transitive description: name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.1.4" yaml_writer: dependency: transitive description: @@ -1531,5 +1571,5 @@ packages: source: hosted version: "2.1.0" sdks: - dart: ">=3.9.0 <4.0.0" - flutter: ">=3.29.0" + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index 93722f1c..c71f0509 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,12 +1,12 @@ name: fluttercon description: "A Flutter app for the FlutterCon conference. This app provides a comprehensive guide to the conference schedule, speakers, and sessions, along with interactive features to enhance the attendee experience." publish_to: 'none' -version: 1.20.02+12002 +version: 1.21.00+12100 environment: sdk: ^3.9.0 -isar_version: &isar_version 3.3.0-dev.3 +isar_version: &isar_version 3.3.2 dependencies: appinio_social_share: ^0.3.2 @@ -85,4 +85,5 @@ flutter: - assets/icons/ - assets/icons/social-media/ - assets/images/ + - assets/images/SVG/ - assets/google_fonts/