diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0be3ecd --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# ReadZero app build-time configuration. +# +# Copy to .env and fill in the values, then build with: +# flutter run --dart-define-from-file=.env +# +# Everything in this file is compiled into the app binary, so ONLY put values +# here that are safe to ship to users. The Supabase anon key is public by +# design (row level security protects the data). +# +# Edge Function secrets (OpenRouter, Jina, xAI, model ids) do NOT belong here. +# They live server-side; see supabase/functions/.env.example and set them with +# `supabase secrets set`. + +SUPABASE_URL=https://.supabase.co +SUPABASE_ANON_KEY= diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..3fe7863 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +version: 2 +updates: + - package-ecosystem: pub + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + riverpod: + patterns: + - "flutter_riverpod" + - "riverpod_*" + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6e7a656 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + flutter: + name: Flutter analyze and test + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - uses: subosito/flutter-action@v2 + with: + # Keep in sync with ios/ci_scripts/ci_post_clone.sh + flutter-version: 3.27.3 + channel: stable + cache: true + + - run: flutter --version + + - run: flutter pub get + + # Pre-existing warnings are tracked as a follow-up; drop + # --no-fatal-warnings once they are cleared. + - run: flutter analyze --no-fatal-infos --no-fatal-warnings + + - run: flutter test diff --git a/.gitignore b/.gitignore index 0327885..08102cf 100644 --- a/.gitignore +++ b/.gitignore @@ -30,7 +30,6 @@ migrate_working_dir/ .pub-cache/ .pub/ /build/ -pubspec.lock # Symbolication related app.*.symbols @@ -103,6 +102,7 @@ supabase/.temp/ .env .env.* *.env +!.env.example # Firebase **/ios/Runner/GoogleService-Info.plist diff --git a/README.md b/README.md index 94e0e6e..6cc970d 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,86 @@ -# ReadZero - Intelligent Reading List +# ReadZero -A cross-platform mobile app that serves as a smart reading list destination. Share any article, blog post, or web page, and get AI-powered daily digests with intelligent summaries. +Read-it-later for iOS with AI daily digests and podcast playback. Share a link from any app, ReadZero extracts the article, analyzes it with Claude, and each morning delivers a digest of everything you saved. Digests can also be listened to as podcast episodes. -![Flutter](https://img.shields.io/badge/Flutter-3.19+-blue) -![Supabase](https://img.shields.io/badge/Supabase-Backend-green) -![Claude AI](https://img.shields.io/badge/Claude-Haiku%203.5-orange) +[![App Store](https://img.shields.io/badge/App_Store-ReadZero-black?logo=apple)](https://apps.apple.com/app/readzero/id6758777327) +![Flutter](https://img.shields.io/badge/Flutter-3.27-blue) +![Supabase](https://img.shields.io/badge/Supabase-Postgres_+_Edge_Functions-green) +![Claude](https://img.shields.io/badge/Claude-Haiku_4.5_via_OpenRouter-orange) +[![CI](https://github.com/bluzername/ReadZero/actions/workflows/ci.yml/badge.svg)](https://github.com/bluzername/ReadZero/actions/workflows/ci.yml) + +- App Store: +- Website, support, privacy and terms: ## Features -- **📱 Share Extension** - Share from any app on iOS -- **📄 Full Content Extraction** - Extracts article text, images, and comments -- **🐦 X/Twitter Support** - Native X post extraction via Grok API -- **🤖 AI Analysis** - Multi-level summaries using Claude via OpenRouter -- **📊 Daily Digest** - Intelligent summaries with theme detection -- **🌙 Dark Mode Icons** - iOS 18+ dark mode app icon support -- **🎨 Beautiful Reader UI** - Clean, modern interface +- **Save from anywhere**: iOS Share Extension writes the URL to an App Group; the app syncs it to Supabase and kicks off extraction. +- **Full content extraction**: Mozilla Readability on the Edge Function, Jina Reader as fallback, Unsplash cover image fallback with photographer credit. +- **X and LinkedIn**: X posts are fetched through Grok's `x_search` tool (xAI). LinkedIn and X can also be extracted on-device through an authenticated `WKWebView` session (`features/auth`). +- **AI analysis**: TL;DR, key points, deeper bullets, topics, sentiment, reading time and content type per article, generated by Claude through OpenRouter. +- **Daily digest**: a pg_cron job calls `generate-digest` every morning; the digest has an overall summary, top themes, per-article highlights and cross-article insights. A local notification fires when it lands. +- **Podcast playback**: digests can be turned into audio episodes, played in-app (mini player, `just_audio`) or subscribed to as a personal RSS feed from any podcast app. +- **Library and archive**: reader UI with markdown rendering, archive, delete, mark as read, JSON export of your data. +- **Anonymous accounts**: the app signs in anonymously on first launch; no sign-up required. + +## How it works + +``` +iOS Share Extension --> App Group --> Flutter app --> Supabase (Postgres, Auth, Realtime) + | + trigger: articles insert -> processing_queue + | + process-queue (Edge Function) + | + extract-article (Edge Function) + Readability / Jina / Grok + Claude via OpenRouter + | + pg_cron 08:00 UTC -> generate-digest (Edge Function) -> digests +``` -## Architecture +### Repository layout ``` -┌─────────────────────────────────────────────────────────────┐ -│ Flutter App │ -│ Share Extension → App Group → Main App → Supabase Sync │ -└─────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ Supabase Backend │ -│ PostgreSQL │ Edge Functions │ Realtime │ Auth │ -└─────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ External Services │ -│ Jina Reader │ Claude (OpenRouter) │ Grok API (xAI) │ -└─────────────────────────────────────────────────────────────┘ +lib/ + main.dart App entry: Supabase init, anonymous sign-in, share channel + core/ + config/env.dart Build-time config (dart-define) + models/models.dart Article, DailyDigest, PodcastEpisode, UserSettings, ... + router/app_router.dart go_router: /, /digest, /settings, /article/:id, /archive + services/ Supabase access, local notifications, authenticated extraction + theme/app_theme.dart Design system + features/ + articles/ Article detail, discover cards, providers + auth/ Web login for LinkedIn/X cookie-based extraction + digest/ Daily digest screen + home/ Library + podcast/ Player state and mini player + settings/ Settings and archive +supabase/ + migrations/ Schema, processing queue, cron job + functions/ + _shared/config.ts Model ids (CLAUDE_MODEL, GROK_MODEL) + extract-article/ Extraction + Claude analysis + generate-digest/ Daily digest generation + process-queue/ Drains processing_queue, calls extract-article +ios/ + Runner/, ShareExtension/ App and share extension + ci_scripts/ci_post_clone.sh Xcode Cloud bootstrap + scripts/validate_and_upload.sh Local archive + App Store Connect upload +website/ GitHub Pages site (index, support, privacy, terms) +docs/ Markdown sources for the legal and support pages +test/ Unit tests (models, restricted domains, config) ``` ## Setup ### Prerequisites -- Flutter SDK 3.19+ -- Supabase account -- OpenRouter API key (for Claude) -- Jina AI API key (for article extraction) -- xAI API key (for X/Twitter extraction) +- Flutter 3.27.3 (the version pinned in CI and Xcode Cloud) +- Xcode with an iOS simulator or device +- A Supabase project +- API keys: OpenRouter (Claude), Jina (fallback extraction), xAI (X posts). Optional: Tavily, Unsplash. -### 1. Clone & Install Dependencies +### 1. Clone and install ```bash git clone https://github.com/bluzername/ReadZero.git @@ -55,93 +88,68 @@ cd ReadZero flutter pub get ``` -### 2. Supabase Setup +### 2. App configuration (dart-define) -1. Create a new Supabase project at [supabase.com](https://supabase.com) +The app never reads config from disk. `SUPABASE_URL` and `SUPABASE_ANON_KEY` are compiled in with `--dart-define`. -2. Run the database migrations: - ```bash - # Copy the SQL from supabase/migrations/ - # Paste and run in Supabase SQL Editor - ``` +```bash +cp .env.example .env # fill in your project URL and anon key +flutter run --dart-define-from-file=.env +``` -3. Deploy Edge Functions: - ```bash - # Install Supabase CLI - npm install -g supabase +Debug builds assert with a clear message when the defines are missing; release builds log and run in offline mode. Only put values in `.env` that are safe to ship inside the app binary (the anon key is public by design; row level security protects the data). - # Login and link - supabase login - supabase link --project-ref YOUR_PROJECT_REF +### 3. Supabase backend - # Set secrets - supabase secrets set OPENROUTER_API_KEY=your_key - supabase secrets set JINA_API_KEY=your_key - supabase secrets set XAI_API_KEY=your_key +```bash +npm install -g supabase +supabase login +supabase link --project-ref YOUR_PROJECT_REF - # Deploy functions - supabase functions deploy extract-article --no-verify-jwt - supabase functions deploy generate-digest --no-verify-jwt - ``` +# Schema (articles, digests, user_settings, processing_queue, triggers) +supabase db push +``` -### 3. Configure the App +`supabase/migrations/20260205_setup_daily_digest_cron.sql` contains `` and `` placeholders. Substitute them before running it (or store the key in Supabase Vault as described in the file comment) so the key is never committed. -Edit `lib/core/config/env.dart`: +### 4. Edge Function secrets and model ids -```dart -class Env { - static const String supabaseUrl = 'https://YOUR_PROJECT.supabase.co'; - static const String supabaseAnonKey = 'YOUR_ANON_KEY'; -} +```bash +cp supabase/functions/.env.example supabase/functions/.env # fill in +supabase secrets set --env-file supabase/functions/.env + +supabase functions deploy extract-article --no-verify-jwt +supabase functions deploy generate-digest --no-verify-jwt +supabase functions deploy process-queue --no-verify-jwt ``` -### 4. iOS Setup +| Variable | Used by | Default | +| --- | --- | --- | +| `OPENROUTER_API_KEY` | extract-article, generate-digest | required | +| `JINA_API_KEY` | extract-article | required | +| `XAI_API_KEY` | extract-article | required | +| `TAVILY_API_KEY` | extract-article | optional | +| `UNSPLASH_ACCESS_KEY` | extract-article | optional | +| `CLAUDE_MODEL` | extract-article, generate-digest | `anthropic/claude-haiku-4.5` (OpenRouter slug) | +| `GROK_MODEL` | extract-article | `grok-4-1-fast` (xAI model id) | -The iOS project is pre-configured with: -- Share Extension for saving URLs -- App Groups for extension communication -- Dark mode app icons +`SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY` are injected by the Supabase runtime; do not set them as secrets. -Bundle ID: `live.bluzername.readzero.app` +### 5. iOS -### 5. Run the App +Bundle ID `live.bluzername.readzero.app`. The Xcode project ships with the Share Extension, App Group and dark-mode icons configured. -```bash -# iOS -flutter run -d ios --release -``` +- **Xcode Cloud**: set `SUPABASE_URL` and `SUPABASE_ANON_KEY` as environment variables on the workflow. `ios/ci_scripts/ci_post_clone.sh` installs Flutter, then runs `flutter build ios --config-only` with those defines so `Generated.xcconfig` carries them into the archive. +- **Local release upload**: `ASC_APP_PASSWORD=... ./ios/scripts/validate_and_upload.sh` reads `.env` the same way, then archives and uploads to App Store Connect. -## Project Structure +## Development +```bash +flutter analyze --no-fatal-infos --no-fatal-warnings +flutter test ``` -lib/ -├── main.dart # App entry point -├── core/ -│ ├── config/env.dart # Environment configuration -│ ├── models/models.dart # Data models -│ ├── router/app_router.dart # Navigation -│ ├── services/supabase_service.dart -│ └── theme/app_theme.dart # Design system -├── features/ -│ ├── articles/ # Article display -│ ├── digest/ # Daily digest -│ ├── home/ # Library view -│ └── settings/ # Settings -supabase/ -├── migrations/ # Database schema -└── functions/ - ├── extract-article/ # Content extraction + Grok - └── generate-digest/ # AI digest generation - -ios/ -├── Runner/ # Main app -└── ShareExtension/ # iOS share extension - -docs/ -├── privacy-policy.md # Privacy Policy -└── terms-of-service.md # Terms of Service -``` +CI (`.github/workflows/ci.yml`) runs the same on every push and pull request. Dependabot keeps pub packages and GitHub Actions current. ## License diff --git a/UI_FIX_PLAN.md b/UI_FIX_PLAN.md deleted file mode 100644 index 9cf7f13..0000000 --- a/UI_FIX_PLAN.md +++ /dev/null @@ -1,224 +0,0 @@ -# UI Fix Plan — Apple Review Rejection - -**Date:** 2026-02-15 -**Rejection Reason:** "Sign In" button doesn't do anything -**Goal:** Fix all dead/broken buttons and misleading UI before resubmission - ---- - -## Test Results Summary - -Full simulator UI test conducted on iPhone 16e (iOS 26.2). Every interactive element was tapped and verified. - -| Element | Location | Status | Issue | -|---------|----------|--------|-------| -| Digest Time | Settings > Daily Digest | PASS | Opens time picker | -| Push Notifications toggle | Settings > Daily Digest | PASS | Toggles correctly | -| Analyze Images toggle | Settings > Content | PASS | Toggles correctly | -| Include Comments toggle | Settings > Content | PASS | Toggles correctly | -| Podcast Feed (no podcast) | Settings > Podcast | **FAIL** | No onTap handler when feedUrl is null — looks tappable but does nothing | -| **Sign In** | Settings > Account | **FAIL** | `// TODO: Auth flow` — completely dead button, has chevron suggesting navigation | -| Sync Status | Settings > Account | **FAIL** | Static "Last synced: Just now" text — misleading, no real sync exists | -| Archived Articles | Settings > Data | PASS | Navigates to archive screen | -| Export Data | Settings > Data | PASS | Opens share sheet with JSON export | -| Clear All Data | Settings > Data | PASS | Shows confirmation dialog | -| Privacy Policy | Settings > About | **WARN** | May fail silently on some devices — `launchUrl` without explicit `LaunchMode` | -| Terms of Service | Settings > About | PASS | Opens in-app browser to GitHub Pages | -| "View all N articles" | Digest card | **FAIL** | `// TODO: Show full digest view` — dead button | -| Library tab (empty) | Bottom nav | PASS | Shows empty state | -| Daily Digest tab (empty) | Bottom nav | PASS | Shows empty state | -| Settings tab | Bottom nav | PASS | Shows settings | - ---- - -## Fixes Required - -### FIX 1: Remove or replace "Sign In" button [CRITICAL — Apple rejection cause] - -**File:** `lib/features/settings/screens/settings_screen.dart` lines 187-195 - -**Problem:** Button has `onTap: () { // TODO: Auth flow }` — does literally nothing. Apple explicitly rejected for this. - -**Option A (Recommended): Remove the entire ACCOUNT section** -The app uses anonymous auth. There is no account system, no email/password, no OAuth. The "Sign In" and "Sync Status" rows are both fiction. Remove the entire section: - -```dart -// DELETE these lines (187-200): -const _SectionHeader(title: 'Account'), -_SettingsTile( - icon: Icons.person_outline, - title: 'Sign In', - subtitle: 'Sync your library across devices', - onTap: () { - // TODO: Auth flow - }, -), -_SettingsTile( - icon: Icons.cloud_sync_outlined, - title: 'Sync Status', - subtitle: 'Last synced: Just now', -), -``` - -**Option B: Implement real Sign In with Apple** -If you want account sync in V1, implement Sign In with Apple (required by Apple if you offer any social login). This is significantly more work: -- Add `sign_in_with_apple` package -- Configure Supabase Apple OAuth provider -- Create auth state management -- Link anonymous data to Apple ID -- Build actual sync logic - -**Recommendation:** Go with Option A. Remove the dead UI now, ship the fix, implement real auth in a future version. - ---- - -### FIX 2: Remove "Sync Status" row [CRITICAL — misleading UI] - -**File:** `lib/features/settings/screens/settings_screen.dart` lines 196-200 - -**Problem:** Shows "Last synced: Just now" but there is no sync functionality. This is misleading to both users and Apple reviewers. - -**Fix:** Remove along with the Sign In button (see Fix 1, Option A). - ---- - -### FIX 3: Fix "View all N articles" dead button in Digest [MODERATE] - -**File:** `lib/features/digest/screens/digest_screen.dart` lines 307-319 - -**Problem:** When a digest has >3 articles, a "View all N articles" TextButton appears with `// TODO: Show full digest view` — it does nothing on tap. - -**Fix:** Remove the article limit and show all articles inline, removing the dead button: - -```dart -// REPLACE lines 303-319: -// OLD: -...digest.articles.take(3).map((article) => _DigestArticleTile( - article: article, - onTap: () => context.push('/article/${article.articleId}'), - )), -if (digest.articles.length > 3) - Padding( - padding: const EdgeInsets.only(top: 8), - child: TextButton( - onPressed: () { - // TODO: Show full digest view - }, - child: Text( - 'View all ${digest.articles.length} articles', - style: TextStyle(color: context.primaryColor), - ), - ), - ), - -// NEW: -...digest.articles.map((article) => _DigestArticleTile( - article: article, - onTap: () => context.push('/article/${article.articleId}'), - )), -``` - ---- - -### FIX 4: Fix Podcast Feed tile when no podcast exists [LOW] - -**File:** `lib/features/settings/screens/settings_screen.dart` lines 139-148 - -**Problem:** When `feedUrl == null`, the tile renders without an `onTap` handler. Visually looks the same as other tiles (same font, same layout). No chevron though, so it's less deceptive than Sign In. - -**Fix:** Make it visually clear this is informational, not tappable. Either: -- Add a muted/disabled style, OR -- Remove the tile entirely when no podcast exists and only show the explanatory text - -```dart -// REPLACE lines 142-148: -if (feedUrl == null) { - return _SettingsTile( - icon: Icons.podcasts_outlined, - title: 'Podcast Feed', - subtitle: 'Save articles to generate your first podcast', - // No onTap — already has no chevron since onTap is null - // The _SettingsTile only shows chevron when onTap != null, so this is OK - ); -} -``` - -**Verdict:** This is actually fine as-is — no chevron is shown when `onTap` is null (the `_SettingsTile` widget only adds a chevron when `onTap != null`). However, Apple might still tap it and notice nothing happens. Safest fix: add an `onTap` that shows a SnackBar saying "Save articles to generate your first podcast episode." - -```dart -if (feedUrl == null) { - return _SettingsTile( - icon: Icons.podcasts_outlined, - title: 'Podcast Feed', - subtitle: 'Save articles to generate your first podcast', - onTap: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Save some articles first — your podcast feed will appear here.'), - duration: Duration(seconds: 2), - ), - ); - }, - ); -} -``` - ---- - -### FIX 5: Ensure Privacy Policy opens reliably [LOW] - -**File:** `lib/features/settings/screens/settings_screen.dart` line 249 - -**Problem:** `launchUrl` without explicit `LaunchMode` may fail silently on some devices/simulators. Terms of Service works but Privacy Policy was inconsistent in testing. - -**Fix:** Add explicit launch mode matching Terms of Service behavior: - -```dart -// OLD: -onTap: () => launchUrl(Uri.parse(Env.privacyPolicyUrl)), - -// NEW: -onTap: () => launchUrl( - Uri.parse(Env.privacyPolicyUrl), - mode: LaunchMode.inAppBrowserView, -), -``` - -Apply the same to Terms of Service for consistency: - -```dart -onTap: () => launchUrl( - Uri.parse(Env.termsOfServiceUrl), - mode: LaunchMode.inAppBrowserView, -), -``` - ---- - -## Implementation Order - -1. **FIX 1 + FIX 2:** Remove ACCOUNT section (Sign In + Sync Status) — **this alone should pass Apple review** -2. **FIX 3:** Remove dead "View all" button in digest -3. **FIX 4:** Add feedback to Podcast Feed tile -4. **FIX 5:** Add explicit LaunchMode to Privacy Policy / ToS - -## Files Changed - -| File | Changes | -|------|---------| -| `lib/features/settings/screens/settings_screen.dart` | Remove ACCOUNT section, fix Podcast Feed tile, fix Privacy Policy launch mode | -| `lib/features/digest/screens/digest_screen.dart` | Remove dead "View all" button, show all articles | - -## Post-Fix Verification - -1. Build to simulator -2. Tap every Settings tile — verify none are dead -3. Navigate to each tab — verify no dead buttons -4. Check Privacy Policy and Terms of Service both open -5. If you have digests with >3 articles, verify they all show without a dead "View all" button - -## Resubmission Notes - -- Bump version/build number in `ios/Runner.xcodeproj/project.pbxproj` (both Runner AND ShareExtension targets) -- Push to main branch for Xcode Cloud to pick up -- In App Store Connect review notes, mention: "Fixed: Removed placeholder Sign In button that was non-functional. The app uses anonymous authentication and does not require user accounts." diff --git a/android/app/src/main/kotlin/com/readwise/share/ShareActivity.kt b/android/app/src/main/kotlin/com/readwise/share/ShareActivity.kt index edb69d8..3a193d4 100644 --- a/android/app/src/main/kotlin/com/readwise/share/ShareActivity.kt +++ b/android/app/src/main/kotlin/com/readwise/share/ShareActivity.kt @@ -65,7 +65,7 @@ class ShareActivity : FlutterActivity() { if (url != null) { // Show confirmation toast - Toast.makeText(this, "Saving to Readwise...", Toast.LENGTH_SHORT).show() + Toast.makeText(this, "Saving to ReadZero...", Toast.LENGTH_SHORT).show() return mapOf( "type" to "url", diff --git a/ios/Flutter/Flutter 2.podspec b/ios/Flutter/Flutter 2.podspec deleted file mode 100644 index 98e1633..0000000 --- a/ios/Flutter/Flutter 2.podspec +++ /dev/null @@ -1,18 +0,0 @@ -# -# This podspec is NOT to be published. It is only used as a local source! -# This is a generated file; do not edit or check into version control. -# - -Pod::Spec.new do |s| - s.name = 'Flutter' - s.version = '1.0.0' - s.summary = 'A UI toolkit for beautiful and fast apps.' - s.homepage = 'https://flutter.dev' - s.license = { :type => 'BSD' } - s.author = { 'Flutter Dev Team' => 'flutter-dev@googlegroups.com' } - s.source = { :git => 'https://github.com/flutter/engine', :tag => s.version.to_s } - s.ios.deployment_target = '12.0' - # Framework linking is handled by Flutter tooling, not CocoaPods. - # Add a placeholder to satisfy `s.dependency 'Flutter'` plugin podspecs. - s.vendored_frameworks = 'path/to/nothing' -end diff --git a/ios/Flutter/Generated 2.xcconfig b/ios/Flutter/Generated 2.xcconfig deleted file mode 100644 index e2c45e7..0000000 --- a/ios/Flutter/Generated 2.xcconfig +++ /dev/null @@ -1,14 +0,0 @@ -// This is a generated file; do not edit or check into version control. -FLUTTER_ROOT=/Users/xx/flutter -FLUTTER_APPLICATION_PATH=/Users/xx/Documents/EB/Projects/readwise-app -COCOAPODS_PARALLEL_CODE_SIGN=true -FLUTTER_TARGET=lib/main.dart -FLUTTER_BUILD_DIR=build -FLUTTER_BUILD_NAME=1.0.0 -FLUTTER_BUILD_NUMBER=1 -EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386 -EXCLUDED_ARCHS[sdk=iphoneos*]=armv7 -DART_OBFUSCATION=false -TRACK_WIDGET_CREATION=false -TREE_SHAKE_ICONS=false -PACKAGE_CONFIG=/Users/xx/Documents/EB/Projects/readwise-app/.dart_tool/package_config.json diff --git a/ios/Flutter/flutter_export_environment 2.sh b/ios/Flutter/flutter_export_environment 2.sh deleted file mode 100755 index 4b11685..0000000 --- a/ios/Flutter/flutter_export_environment 2.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/sh -# This is a generated file; do not edit or check into version control. -export "FLUTTER_ROOT=/Users/xx/flutter" -export "FLUTTER_APPLICATION_PATH=/Users/xx/Documents/EB/Projects/readwise-app" -export "COCOAPODS_PARALLEL_CODE_SIGN=true" -export "FLUTTER_TARGET=lib/main.dart" -export "FLUTTER_BUILD_DIR=build" -export "FLUTTER_BUILD_NAME=1.0.0" -export "FLUTTER_BUILD_NUMBER=1" -export "DART_OBFUSCATION=false" -export "TRACK_WIDGET_CREATION=false" -export "TREE_SHAKE_ICONS=false" -export "PACKAGE_CONFIG=/Users/xx/Documents/EB/Projects/readwise-app/.dart_tool/package_config.json" diff --git a/ios/ci_scripts/ci_post_clone.sh b/ios/ci_scripts/ci_post_clone.sh index b2bce6c..bbc6821 100755 --- a/ios/ci_scripts/ci_post_clone.sh +++ b/ios/ci_scripts/ci_post_clone.sh @@ -39,6 +39,24 @@ else exit 1 fi +# Bake the build-time configuration into Generated.xcconfig (DART_DEFINES). +# SUPABASE_URL and SUPABASE_ANON_KEY must be set as environment variables in +# the Xcode Cloud workflow. `flutter pub get` above rewrote Generated.xcconfig +# without defines, so this step must run after it. +echo "=== Writing dart-defines into Generated.xcconfig ===" +: "${SUPABASE_URL:?SUPABASE_URL must be set in the Xcode Cloud workflow environment}" +: "${SUPABASE_ANON_KEY:?SUPABASE_ANON_KEY must be set in the Xcode Cloud workflow environment}" +flutter build ios --config-only --release --no-codesign \ + --dart-define=SUPABASE_URL="$SUPABASE_URL" \ + --dart-define=SUPABASE_ANON_KEY="$SUPABASE_ANON_KEY" + +if grep -q "^DART_DEFINES=" ios/Flutter/Generated.xcconfig; then + echo "=== DART_DEFINES written ===" +else + echo "ERROR: DART_DEFINES missing from Generated.xcconfig" + exit 1 +fi + # Install CocoaPods dependencies echo "=== Running pod install ===" cd ios diff --git a/ios/scripts/validate_and_upload.sh b/ios/scripts/validate_and_upload.sh index ac09e84..9acf1e5 100755 --- a/ios/scripts/validate_and_upload.sh +++ b/ios/scripts/validate_and_upload.sh @@ -6,7 +6,7 @@ set -euo pipefail # Archives, exports, and uploads to App Store Connect. # # If ExportOptions.plist has destination=upload (default), the -# export step uploads directly to ASC — no separate altool step. +# export step uploads directly to ASC - no separate altool step. # # If you want local validation first, change ExportOptions.plist # destination to "export", then this script will validate via @@ -22,15 +22,31 @@ PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" echo "" echo "╔══════════════════════════════════════════════════╗" -echo "║ ReadZero — Build, Validate & Upload to ASC ║" +echo "║ ReadZero - Build, Validate & Upload to ASC ║" echo "╚══════════════════════════════════════════════════╝" echo "" echo "Project: $PROJECT_ROOT" echo "" +# ─── Step 0: Bake build-time config into Generated.xcconfig ── +# The archive reads DART_DEFINES from ios/Flutter/Generated.xcconfig, which +# `flutter pub get` rewrites without defines. Regenerate it from .env first. +ENV_FILE="$PROJECT_ROOT/.env" +if [ ! -f "$ENV_FILE" ]; then + echo "ERROR: $ENV_FILE not found." + echo "Copy .env.example to .env and set SUPABASE_URL and SUPABASE_ANON_KEY." + exit 1 +fi +echo "=== Step 0: Write dart-defines from .env ===" +cd "$PROJECT_ROOT" +flutter build ios --config-only --release --no-codesign \ + --dart-define-from-file="$ENV_FILE" +echo "Config: OK" +echo "" + # ─── Step 1: Archive ───────────────────────────────────────── # xcodebuild archive runs Flutter's build phases automatically, -# so a separate `flutter build ios` is not needed. +# so a separate full `flutter build ios` is not needed. echo "=== Step 1: Xcode Archive ===" cd "$PROJECT_ROOT/ios" xcodebuild -workspace Runner.xcworkspace \ @@ -57,7 +73,7 @@ echo "" IPA_FILE=$(find build/ipa -name "*.ipa" -print -quit 2>/dev/null || true) if [ -n "$IPA_FILE" ]; then - # Local IPA exists — validate then upload via altool + # Local IPA exists - validate then upload via altool echo "IPA: $IPA_FILE" echo "" @@ -76,13 +92,13 @@ if [ -n "$IPA_FILE" ]; then -f "$IPA_FILE" -t ios \ -u "$APPLE_ID" -p "$APP_PASSWORD" else - # No local IPA — export already uploaded directly to ASC - echo "(ExportOptions destination=upload — build was uploaded during export)" + # No local IPA - export already uploaded directly to ASC + echo "(ExportOptions destination=upload - build was uploaded during export)" fi echo "" echo "╔══════════════════════════════════════════════════╗" -echo "║ SUCCESS — Build uploaded to App Store Connect ║" +echo "║ SUCCESS - Build uploaded to App Store Connect ║" echo "║ Check ASC in 15-30 min for processing. ║" echo "╚══════════════════════════════════════════════════╝" echo "" diff --git a/jan29.md b/jan29.md deleted file mode 100644 index 0bed19e..0000000 --- a/jan29.md +++ /dev/null @@ -1,760 +0,0 @@ -# Readwise App - Development Notes (Jan 29, 2026) - -## Project Status Assessment - -### What's Complete -- Share extension URL capture (Android/iOS intent handling) -- Article library view with date-based grouping -- Article detail screen with markdown rendering, progress tracking -- AI summary cards (key points, topics, image analysis) -- Daily digest generation with cross-article insights -- Supabase backend (PostgreSQL, Edge Functions, RLS policies) -- Real-time sync via Riverpod streams -- Processing queue with retry logic - -### What's Missing -- Settings screen (10 TODOs - all toggles are placeholders) -- User authentication (currently anonymous only) -- Search functionality -- Archive view -- Data export -- Push notifications (Firebase setup incomplete) - -### Testing Status -- **No automated tests exist** - no `test/` directory -- Manual testing possible once Supabase is configured - ---- - -## Settings Screen Implementation Plan - -### Database Schema (Already Exists) - -The `user_settings` table in Supabase already has all required fields: - -```sql -CREATE TABLE public.user_settings ( - id UUID PRIMARY KEY, - user_id UUID REFERENCES auth.users(id), - digest_time TIME DEFAULT '08:00:00', - timezone TEXT DEFAULT 'America/Los_Angeles', - analyze_images BOOLEAN DEFAULT true, - include_comments BOOLEAN DEFAULT true, - push_notifications BOOLEAN DEFAULT true, - fcm_token TEXT, - created_at TIMESTAMPTZ, - updated_at TIMESTAMPTZ -); -``` - -### Step 1: Add UserSettings Model - -**File:** `lib/core/models/models.dart` - -Add at the end of the file: - -```dart -/// User preferences and settings -class UserSettings { - final String userId; - final TimeOfDay digestTime; - final String timezone; - final bool analyzeImages; - final bool includeComments; - final bool pushNotifications; - final String? fcmToken; - - UserSettings({ - required this.userId, - this.digestTime = const TimeOfDay(hour: 8, minute: 0), - this.timezone = 'America/Los_Angeles', - this.analyzeImages = true, - this.includeComments = true, - this.pushNotifications = true, - this.fcmToken, - }); - - factory UserSettings.fromJson(Map json) { - // Parse TIME string "08:00:00" to TimeOfDay - TimeOfDay parseTime(String? timeStr) { - if (timeStr == null) return const TimeOfDay(hour: 8, minute: 0); - final parts = timeStr.split(':'); - return TimeOfDay( - hour: int.parse(parts[0]), - minute: int.parse(parts[1]), - ); - } - - return UserSettings( - userId: json['user_id'] as String, - digestTime: parseTime(json['digest_time'] as String?), - timezone: json['timezone'] as String? ?? 'America/Los_Angeles', - analyzeImages: json['analyze_images'] as bool? ?? true, - includeComments: json['include_comments'] as bool? ?? true, - pushNotifications: json['push_notifications'] as bool? ?? true, - fcmToken: json['fcm_token'] as String?, - ); - } - - Map toJson() => { - 'user_id': userId, - 'digest_time': '${digestTime.hour.toString().padLeft(2, '0')}:${digestTime.minute.toString().padLeft(2, '0')}:00', - 'timezone': timezone, - 'analyze_images': analyzeImages, - 'include_comments': includeComments, - 'push_notifications': pushNotifications, - 'fcm_token': fcmToken, - }; - - UserSettings copyWith({ - String? userId, - TimeOfDay? digestTime, - String? timezone, - bool? analyzeImages, - bool? includeComments, - bool? pushNotifications, - String? fcmToken, - }) { - return UserSettings( - userId: userId ?? this.userId, - digestTime: digestTime ?? this.digestTime, - timezone: timezone ?? this.timezone, - analyzeImages: analyzeImages ?? this.analyzeImages, - includeComments: includeComments ?? this.includeComments, - pushNotifications: pushNotifications ?? this.pushNotifications, - fcmToken: fcmToken ?? this.fcmToken, - ); - } -} -``` - -Don't forget to add `import 'package:flutter/material.dart';` at the top for `TimeOfDay`. - ---- - -### Step 2: Add Service Methods - -**File:** `lib/core/services/supabase_service.dart` - -Add these methods to the `SupabaseService` class: - -```dart -// ============ Settings ============ - -/// Get user settings -Future getUserSettings() async { - final response = await _client - .from('user_settings') - .select() - .eq('user_id', userId!) - .maybeSingle(); - - if (response == null) { - // Settings should be auto-created by DB trigger, but handle edge case - final created = await _client.from('user_settings').insert({ - 'user_id': userId, - }).select().single(); - return UserSettings.fromJson(created); - } - return UserSettings.fromJson(response); -} - -/// Update a single setting -Future updateSetting(String key, dynamic value) async { - await _client.from('user_settings').update({ - key: value, - 'updated_at': DateTime.now().toIso8601String(), - }).eq('user_id', userId!); -} - -/// Update digest time -Future updateDigestTime(TimeOfDay time) async { - final timeString = '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}:00'; - await updateSetting('digest_time', timeString); -} - -/// Update FCM token for push notifications -Future updateFcmToken(String? token) async { - await updateSetting('fcm_token', token); -} - -/// Get all articles for export (including archived) -Future> getAllArticlesForExport() async { - final response = await _client - .from('articles') - .select() - .eq('user_id', userId!) - .order('created_at', ascending: false); - return (response as List).map((e) => Article.fromJson(e)).toList(); -} - -/// Get all digests for export -Future> getAllDigestsForExport() async { - final response = await _client - .from('digests') - .select() - .eq('user_id', userId!) - .order('date', ascending: false); - return (response as List).map((e) => DailyDigest.fromJson(e)).toList(); -} - -/// Delete all user data (articles and digests) -Future clearAllData() async { - await _client.from('articles').delete().eq('user_id', userId!); - await _client.from('digests').delete().eq('user_id', userId!); -} - -/// Stream of archived articles -Stream> watchArchivedArticles() { - return _client - .from('articles') - .stream(primaryKey: ['id']) - .eq('user_id', userId!) - .order('created_at', ascending: false) - .map((data) => data - .map((e) => Article.fromJson(e)) - .where((a) => a.isArchived) - .toList()); -} - -/// Restore archived article -Future restoreArticle(String id) async { - await _client.from('articles').update({ - 'is_archived': false, - }).eq('id', id); -} -``` - ---- - -### Step 3: Create Settings Providers - -**New File:** `lib/features/settings/providers/settings_providers.dart` - -```dart -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../core/services/supabase_service.dart'; -import '../../../core/models/models.dart'; -import '../../articles/providers/article_providers.dart'; - -/// User settings provider -final userSettingsProvider = AsyncNotifierProvider(() { - return UserSettingsNotifier(); -}); - -class UserSettingsNotifier extends AsyncNotifier { - @override - Future build() async { - return ref.read(supabaseServiceProvider).getUserSettings(); - } - - Future updateDigestTime(TimeOfDay time) async { - final oldState = state.value!; - state = AsyncData(oldState.copyWith(digestTime: time)); - try { - await ref.read(supabaseServiceProvider).updateDigestTime(time); - } catch (e) { - state = AsyncData(oldState); - rethrow; - } - } - - Future togglePushNotifications(bool value) async { - final oldState = state.value!; - state = AsyncData(oldState.copyWith(pushNotifications: value)); - try { - await ref.read(supabaseServiceProvider).updateSetting('push_notifications', value); - } catch (e) { - state = AsyncData(oldState); - rethrow; - } - } - - Future toggleAnalyzeImages(bool value) async { - final oldState = state.value!; - state = AsyncData(oldState.copyWith(analyzeImages: value)); - try { - await ref.read(supabaseServiceProvider).updateSetting('analyze_images', value); - } catch (e) { - state = AsyncData(oldState); - rethrow; - } - } - - Future toggleIncludeComments(bool value) async { - final oldState = state.value!; - state = AsyncData(oldState.copyWith(includeComments: value)); - try { - await ref.read(supabaseServiceProvider).updateSetting('include_comments', value); - } catch (e) { - state = AsyncData(oldState); - rethrow; - } - } -} - -/// Provider for archived articles stream -final archivedArticlesProvider = StreamProvider>((ref) { - return ref.read(supabaseServiceProvider).watchArchivedArticles(); -}); -``` - ---- - -### Step 4: Update Configuration - -**File:** `lib/core/config/env.dart` - -Add these constants: - -```dart -// Legal URLs (update with your actual URLs) -static const String privacyPolicyUrl = 'https://yourapp.com/privacy'; -static const String termsOfServiceUrl = 'https://yourapp.com/terms'; -``` - ---- - -### Step 5: Create Archive Screen - -**New File:** `lib/features/settings/screens/archive_screen.dart` - -```dart -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../../../core/theme/app_theme.dart'; -import '../../articles/providers/article_providers.dart'; -import '../providers/settings_providers.dart'; - -class ArchiveScreen extends ConsumerWidget { - const ArchiveScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final archivedArticles = ref.watch(archivedArticlesProvider); - - return Scaffold( - body: CustomScrollView( - slivers: [ - SliverAppBar( - floating: true, - title: const Text('Archived Articles'), - ), - archivedArticles.when( - data: (articles) { - if (articles.isEmpty) { - return SliverFillRemaining( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.archive_outlined, - size: 64, - color: context.mutedTextColor, - ), - const SizedBox(height: 16), - Text( - 'No archived articles', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: context.mutedTextColor, - ), - ), - const SizedBox(height: 8), - Text( - 'Archived articles will appear here', - style: TextStyle(color: context.mutedTextColor), - ), - ], - ), - ), - ); - } - return SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - final article = articles[index]; - return ListTile( - leading: article.imageUrl != null - ? ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.network( - article.imageUrl!, - width: 56, - height: 56, - fit: BoxFit.cover, - ), - ) - : Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: context.surfaceColor, - borderRadius: BorderRadius.circular(8), - ), - child: Icon( - Icons.article_outlined, - color: context.mutedTextColor, - ), - ), - title: Text( - article.title ?? 'Untitled', - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - subtitle: Text( - article.siteName ?? Uri.parse(article.url).host, - style: TextStyle(color: context.mutedTextColor), - ), - trailing: PopupMenuButton( - itemBuilder: (context) => [ - const PopupMenuItem( - value: 'restore', - child: ListTile( - leading: Icon(Icons.unarchive_outlined), - title: Text('Restore'), - contentPadding: EdgeInsets.zero, - ), - ), - const PopupMenuItem( - value: 'delete', - child: ListTile( - leading: Icon(Icons.delete_outline, color: Colors.red), - title: Text('Delete', style: TextStyle(color: Colors.red)), - contentPadding: EdgeInsets.zero, - ), - ), - ], - onSelected: (value) async { - final service = ref.read(supabaseServiceProvider); - if (value == 'restore') { - await service.restoreArticle(article.id); - } else if (value == 'delete') { - await service.deleteArticle(article.id); - } - }, - ), - onTap: () => context.push('/article/${article.id}'), - ); - }, - childCount: articles.length, - ), - ); - }, - loading: () => const SliverFillRemaining( - child: Center(child: CircularProgressIndicator()), - ), - error: (error, _) => SliverFillRemaining( - child: Center(child: Text('Error: $error')), - ), - ), - ], - ), - ); - } -} -``` - ---- - -### Step 6: Update Router - -**File:** `lib/core/router/app_router.dart` - -Add the archive route. First import: -```dart -import '../../features/settings/screens/archive_screen.dart'; -``` - -Then add this route alongside your other routes: -```dart -GoRoute( - path: '/archive', - name: 'archive', - builder: (context, state) => const ArchiveScreen(), -), -``` - ---- - -### Step 7: Update Settings Screen - -**File:** `lib/features/settings/screens/settings_screen.dart` - -Replace the entire file with the updated implementation (see below for key changes): - -**Add imports at the top:** -```dart -import 'dart:convert'; -import 'package:url_launcher/url_launcher.dart'; -import 'package:go_router/go_router.dart'; -import 'package:share_plus/share_plus.dart'; // Add to pubspec if needed -import 'package:path_provider/path_provider.dart'; -import 'dart:io'; -import '../../../core/config/env.dart'; -import '../providers/settings_providers.dart'; -``` - -**Key changes to the widget:** - -1. Watch settings provider at the start of build: -```dart -final settingsAsync = ref.watch(userSettingsProvider); -``` - -2. **Digest Time (line 30):** -```dart -_SettingsTile( - icon: Icons.schedule_outlined, - title: 'Digest Time', - subtitle: settingsAsync.when( - data: (s) => '${s.digestTime.hourOfPeriod}:${s.digestTime.minute.toString().padLeft(2, '0')} ${s.digestTime.period == DayPeriod.am ? 'AM' : 'PM'}', - loading: () => 'Loading...', - error: (_, __) => '8:00 AM', - ), - onTap: () async { - final settings = ref.read(userSettingsProvider).value; - if (settings == null) return; - final picked = await showTimePicker( - context: context, - initialTime: settings.digestTime, - ); - if (picked != null) { - ref.read(userSettingsProvider.notifier).updateDigestTime(picked); - } - }, -), -``` - -3. **Push Notifications (line 40):** -```dart -trailing: settingsAsync.when( - data: (s) => Switch( - value: s.pushNotifications, - onChanged: (value) { - ref.read(userSettingsProvider.notifier).togglePushNotifications(value); - }, - ), - loading: () => const SizedBox(width: 48, height: 24, child: LinearProgressIndicator()), - error: (_, __) => Switch(value: false, onChanged: null), -), -``` - -4. **Analyze Images (line 54):** -```dart -trailing: settingsAsync.when( - data: (s) => Switch( - value: s.analyzeImages, - onChanged: (value) { - ref.read(userSettingsProvider.notifier).toggleAnalyzeImages(value); - }, - ), - loading: () => const SizedBox(width: 48, height: 24, child: LinearProgressIndicator()), - error: (_, __) => Switch(value: true, onChanged: null), -), -``` - -5. **Include Comments (line 65):** -```dart -trailing: settingsAsync.when( - data: (s) => Switch( - value: s.includeComments, - onChanged: (value) { - ref.read(userSettingsProvider.notifier).toggleIncludeComments(value); - }, - ), - loading: () => const SizedBox(width: 48, height: 24, child: LinearProgressIndicator()), - error: (_, __) => Switch(value: true, onChanged: null), -), -``` - -6. **Auth Flow (line 77):** -```dart -onTap: () { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Sign In'), - content: const Text('Account sign-in is coming soon! Your data is currently synced anonymously.'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('OK'), - ), - ], - ), - ); -}, -``` - -7. **Archive View (line 93):** -```dart -onTap: () => context.push('/archive'), -``` - -8. **Export Data (line 101):** -```dart -onTap: () => _showExportDialog(context, ref), -``` - -Add this method to the class: -```dart -void _showExportDialog(BuildContext context, WidgetRef ref) { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Export Data'), - content: const Text('Export all your articles and digests as a JSON file?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () async { - Navigator.pop(context); - await _performExport(context, ref); - }, - child: const Text('Export'), - ), - ], - ), - ); -} - -Future _performExport(BuildContext context, WidgetRef ref) async { - showDialog( - context: context, - barrierDismissible: false, - builder: (context) => const Center(child: CircularProgressIndicator()), - ); - - try { - final service = ref.read(supabaseServiceProvider); - final articles = await service.getAllArticlesForExport(); - final digests = await service.getAllDigestsForExport(); - - final exportData = { - 'exportedAt': DateTime.now().toIso8601String(), - 'articles': articles.map((a) => a.toJson()).toList(), - 'digests': digests.map((d) => d.toJson()).toList(), - }; - - final jsonString = const JsonEncoder.withIndent(' ').convert(exportData); - - // Save to temp file and share - final tempDir = await getTemporaryDirectory(); - final file = File('${tempDir.path}/readwise_export_${DateTime.now().millisecondsSinceEpoch}.json'); - await file.writeAsString(jsonString); - - Navigator.pop(context); - - await Share.shareXFiles([XFile(file.path)], subject: 'Readwise Data Export'); - } catch (e) { - Navigator.pop(context); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Export failed: $e')), - ); - } -} -``` - -9. **Privacy Policy (line 125):** -```dart -onTap: () async { - final url = Uri.parse(Env.privacyPolicyUrl); - if (await canLaunchUrl(url)) { - await launchUrl(url, mode: LaunchMode.externalApplication); - } -}, -``` - -10. **Terms of Service (line 132):** -```dart -onTap: () async { - final url = Uri.parse(Env.termsOfServiceUrl); - if (await canLaunchUrl(url)) { - await launchUrl(url, mode: LaunchMode.externalApplication); - } -}, -``` - -11. **Clear All Data (line 183):** -```dart -onPressed: () async { - Navigator.pop(context); - - showDialog( - context: context, - barrierDismissible: false, - builder: (context) => const Center(child: CircularProgressIndicator()), - ); - - try { - await ref.read(supabaseServiceProvider).clearAllData(); - Navigator.pop(context); - - ref.invalidate(articlesStreamProvider); - ref.invalidate(digestsStreamProvider); - - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('All data cleared')), - ); - } catch (e) { - Navigator.pop(context); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to clear data: $e')), - ); - } -}, -``` - ---- - -### Step 8: Add share_plus (if needed) - -**File:** `pubspec.yaml` - -Add under dependencies: -```yaml -share_plus: ^7.2.1 -``` - -Then run `flutter pub get`. - ---- - -## Summary of Files to Change - -| File | Action | -|------|--------| -| `lib/core/models/models.dart` | Add `UserSettings` class | -| `lib/core/services/supabase_service.dart` | Add 8 new methods | -| `lib/core/config/env.dart` | Add URL constants | -| `lib/core/router/app_router.dart` | Add `/archive` route | -| `lib/features/settings/screens/settings_screen.dart` | Implement all TODOs | -| `lib/features/settings/providers/settings_providers.dart` | **CREATE NEW** | -| `lib/features/settings/screens/archive_screen.dart` | **CREATE NEW** | -| `pubspec.yaml` | Add `share_plus` if not present | - ---- - -## Testing Checklist - -- [ ] Open Settings screen - settings load without error -- [ ] Tap Digest Time - time picker appears, selection saves -- [ ] Toggle Push Notifications - switch works, persists on restart -- [ ] Toggle Analyze Images - switch works, persists on restart -- [ ] Toggle Include Comments - switch works, persists on restart -- [ ] Tap Sign In - "Coming Soon" dialog appears -- [ ] Tap Archived Articles - archive screen opens -- [ ] Archive an article, verify it appears in archive -- [ ] Restore archived article, verify it returns to library -- [ ] Tap Export Data - JSON file generated and share sheet opens -- [ ] Tap Privacy Policy - opens URL in browser -- [ ] Tap Terms of Service - opens URL in browser -- [ ] Tap Clear All Data - confirmation dialog appears -- [ ] Confirm Clear All Data - articles and digests are deleted diff --git a/lib/core/config/env.dart b/lib/core/config/env.dart index 5d9a668..f648d97 100644 --- a/lib/core/config/env.dart +++ b/lib/core/config/env.dart @@ -1,17 +1,39 @@ -/// Environment configuration -/// Replace these values with your actual credentials +/// Build-time configuration. +/// +/// Values are injected with `--dart-define` (or `--dart-define-from-file=.env`, +/// see `.env.example` and the README). Nothing here is read at runtime from +/// disk, so a build made without the defines has empty strings. class Env { // Supabase - static const String supabaseUrl = 'https://bioiacixxauufpvswlxe.supabase.co'; - static const String supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImJpb2lhY2l4eGF1dWZwdnN3bHhlIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njk2OTc2MzAsImV4cCI6MjA4NTI3MzYzMH0.rALzbiI5ggIQYOrcPC8VeRVyJO_0KNVtQCaVKz7d9cQ'; - - // Jina Reader API (for article extraction) - static const String jinaApiKey = 'YOUR_JINA_API_KEY'; - - // Claude API (stored in Supabase Edge Function secrets, not here) - // Set via: supabase secrets set ANTHROPIC_API_KEY=your_key + static const String supabaseUrl = String.fromEnvironment('SUPABASE_URL'); + static const String supabaseAnonKey = + String.fromEnvironment('SUPABASE_ANON_KEY'); + + /// True when both Supabase defines were supplied at build time. + static bool get isConfigured => + supabaseUrl.isNotEmpty && supabaseAnonKey.isNotEmpty; + + /// Names of the required defines that are missing from this build. + static List get missingDefines => [ + if (supabaseUrl.isEmpty) 'SUPABASE_URL', + if (supabaseAnonKey.isEmpty) 'SUPABASE_ANON_KEY', + ]; + + static String get missingConfigMessage => + 'Missing build-time config: ${missingDefines.join(', ')}. ' + 'Pass them with --dart-define or --dart-define-from-file=.env ' + '(copy .env.example to .env).'; + + /// Throws a [StateError] with a clear message when the build lacks the + /// required Supabase defines. + static void ensureConfigured() { + if (isConfigured) return; + throw StateError(missingConfigMessage); + } // Legal URLs (hosted on GitHub Pages) - static const String privacyPolicyUrl = 'https://bluzername.github.io/ReadZero/privacy-policy'; - static const String termsOfServiceUrl = 'https://bluzername.github.io/ReadZero/terms-of-service'; + static const String privacyPolicyUrl = + 'https://bluzername.github.io/ReadZero/privacy-policy'; + static const String termsOfServiceUrl = + 'https://bluzername.github.io/ReadZero/terms-of-service'; } diff --git a/lib/core/models/models.dart b/lib/core/models/models.dart index ce04d52..ad73353 100644 --- a/lib/core/models/models.dart +++ b/lib/core/models/models.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import '../config/env.dart'; + /// Represents a saved article/webpage class Article { final String id; @@ -556,7 +558,7 @@ class UserSettings { String? get podcastFeedUrl { if (podcastToken == null) return null; - return 'https://bioiacixxauufpvswlxe.supabase.co/functions/v1/podcast-feed?token=$podcastToken'; + return '${Env.supabaseUrl}/functions/v1/podcast-feed?token=$podcastToken'; } Map toJson() => { diff --git a/lib/main.dart b/lib/main.dart index 1b6ee15..3a439c4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,11 +15,10 @@ const _shareChannel = MethodChannel('com.readzero.app/share'); void main() async { WidgetsFlutterBinding.ensureInitialized(); - // Initialize Supabase (skip if not configured) - if (Env.supabaseUrl.isNotEmpty && - !Env.supabaseUrl.contains('YOUR_') && - Env.supabaseAnonKey.isNotEmpty && - !Env.supabaseAnonKey.contains('YOUR_')) { + // Supabase URL/key come from --dart-define (see .env.example). Debug builds + // fail fast with a clear message; release builds log and run offline. + assert(Env.isConfigured, Env.missingConfigMessage); + if (Env.isConfigured) { try { await Supabase.initialize( url: Env.supabaseUrl, @@ -39,7 +38,8 @@ void main() async { debugPrint('Supabase initialization failed: $e'); } } else { - debugPrint('Supabase not configured - running in offline mode'); + debugPrint('Supabase not configured - running in offline mode. ' + '${Env.missingConfigMessage}'); } // Initialize local notifications diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..ff2ddb3 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,1474 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f + url: "https://pub.dev" + source: hosted + version: "85.0.0" + adaptive_number: + dependency: transitive + description: + name: adaptive_number + sha256: "3a567544e9b5c9c803006f51140ad544aedc79604fd4f3f2c1380003f97c1d77" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: f4ad0fea5f102201015c9aae9d93bc02f75dd9491529a8c21f88d17a8523d44c + url: "https://pub.dev" + source: hosted + version: "7.6.0" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: a5ab7590c27b779f3d4de67f31c4109dbe13dd7339f86461a6f2a8ab2594d8ce + url: "https://pub.dev" + source: hosted + version: "0.13.4" + app_links: + dependency: transitive + description: + name: app_links + sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" + archive: + dependency: transitive + description: + name: archive + sha256: "6c5bcd986e06b94e3c40244af471750840a3d2341d1f9763a1100a14add517b4" + url: "https://pub.dev" + source: hosted + version: "4.3.0" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + audio_session: + dependency: "direct main" + description: + name: audio_session + sha256: f9e7711a0e24ca8b40f5d7ac374c3c6e55016f3157912badd18199cdbbca5c4d + url: "https://pub.dev" + source: hosted + version: "0.2.4" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + build: + dependency: transitive + description: + name: build + sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + 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: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0 + url: "https://pub.dev" + source: hosted + version: "2.4.4" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "058fe9dce1de7d69c4b84fada934df3e0153dd000758c4d65964d0166779aa99" + url: "https://pub.dev" + source: hosted + version: "2.4.15" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021" + url: "https://pub.dev" + source: hosted + version: "8.0.0" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: f87ea98192116f7093cb214551ce1929caae0681fdba282b3d8b4462adee7bb7 + url: "https://pub.dev" + source: hosted + version: "8.13.0" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + characters: + dependency: transitive + description: + name: characters + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + url: "https://pub.dev" + source: hosted + version: "2.0.3" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.dev" + source: hosted + version: "1.1.1" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" + url: "https://pub.dev" + source: hosted + version: "4.10.1" + collection: + dependency: transitive + description: + name: collection + sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf + url: "https://pub.dev" + source: hosted + version: "1.19.0" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + url: "https://pub.dev" + source: hosted + version: "0.3.4+2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + custom_lint_core: + dependency: transitive + description: + name: custom_lint_core + sha256: "31110af3dde9d29fb10828ca33f1dce24d2798477b167675543ce3d208dee8be" + url: "https://pub.dev" + source: hosted + version: "0.7.5" + custom_lint_visitor: + dependency: transitive + description: + name: custom_lint_visitor + sha256: "4a86a0d8415a91fbb8298d6ef03e9034dc8e323a599ddc4120a0e36c433983a2" + url: "https://pub.dev" + source: hosted + version: "1.0.0+7.7.0" + dart_jsonwebtoken: + dependency: transitive + description: + name: dart_jsonwebtoken + sha256: "00a0812d2aeaeb0d30bcbc4dd3cee57971dbc0ab2216adf4f0247f37793f15ef" + url: "https://pub.dev" + source: hosted + version: "2.17.0" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "27eb0ae77836989a3bc541ce55595e8ceee0992807f14511552a898ddd0d88ac" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + dbus: + dependency: transitive + description: + name: dbus + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + url: "https://pub.dev" + source: hosted + version: "0.7.12" + dio: + dependency: "direct main" + description: + name: dio + sha256: "852ec3b48cc431ac04fff978413c541502b67ffc3e26921e74e3d994694192c1" + url: "https://pub.dev" + source: hosted + version: "5.11.1" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "3a1b2cd7be71086f38504956e3ebcd2837288d231ff454bafa78021244102bfc" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + ed25519_edwards: + dependency: transitive + description: + name: ed25519_edwards + sha256: f3c3b109a0e470ee35f6346a155cab290578660852237fce10df6bbd92758534 + url: "https://pub.dev" + source: hosted + version: "0.3.2" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_animate: + dependency: "direct main" + description: + name: flutter_animate + sha256: "7befe2d3252728afb77aecaaea1dec88a89d35b9b1d2eea6d04479e8af9117b5" + url: "https://pub.dev" + source: hosted + version: "4.5.2" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" + url: "https://pub.dev" + source: hosted + version: "0.14.4" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "55b9b229307a10974b26296ff29f2e132256ba4bd74266939118eaefa941cb00" + url: "https://pub.dev" + source: hosted + version: "16.3.3" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af + url: "https://pub.dev" + source: hosted + version: "4.0.1" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66" + url: "https://pub.dev" + source: hosted + version: "7.2.0" + flutter_markdown: + dependency: "direct main" + description: + name: flutter_markdown + sha256: "08fb8315236099ff8e90cb87bb2b935e0a724a3af1623000a9cec930468e0f27" + url: "https://pub.dev" + source: hosted + version: "0.7.7+1" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + flutter_shaders: + dependency: transitive + description: + name: flutter_shaders + sha256: "34794acadd8275d971e02df03afee3dee0f98dbfb8c4837082ad0034f612a3e2" + url: "https://pub.dev" + source: hosted + version: "0.1.3" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + functions_client: + dependency: transitive + description: + name: functions_client + sha256: e9685e9ab852a8b8e0579c867f6c9155da27317b294b3df796a536b8b8e0d253 + url: "https://pub.dev" + source: hosted + version: "2.6.4" + glob: + dependency: transitive + description: + name: glob + sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: b465e99ce64ba75e61c8c0ce3d87b66d8ac07f0b35d0a7e0263fcfc10f99e836 + url: "https://pub.dev" + source: hosted + version: "13.2.5" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: df9763500dadba0155373e9cb44e202ce21bd9ed5de6bdbd05c5854e86839cb8 + url: "https://pub.dev" + source: hosted + version: "6.3.0" + gotrue: + dependency: transitive + description: + name: gotrue + sha256: ac6b664b35304aece2034c66bc09ce761f9f7c8fb6066bbb6b2932aa87d82653 + url: "https://pub.dev" + source: hosted + version: "2.25.0" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + gtk: + dependency: transitive + description: + name: gtk + sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + html: + dependency: "direct main" + description: + name: html + sha256: "43b67b8f43321ab066817dfac5619596c98bb1b61624e77203bb4351785f9699" + url: "https://pub.dev" + source: hosted + version: "0.15.7" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: a1e7f4951e538a568e14b856702afc9ae1d2f4b202daced8d22c1b9cd211ce89 + url: "https://pub.dev" + source: hosted + version: "4.10.1" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + io: + dependency: transitive + description: + name: io + sha256: "2635216ca6a737e60de577ffa1a48a0bec76ca8a62917cfc1bb88c14c570646f" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c + url: "https://pub.dev" + source: hosted + version: "6.9.5" + just_audio: + dependency: "direct main" + description: + name: just_audio + sha256: e60aa97b233ddea025e13dfac59195207f528fa5e9e4a4a49a8c0766701e5fe6 + url: "https://pub.dev" + source: hosted + version: "0.10.6" + just_audio_platform_interface: + dependency: transitive + description: + name: just_audio_platform_interface + sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + just_audio_web: + dependency: transitive + description: + name: just_audio_web + sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663" + url: "https://pub.dev" + source: hosted + version: "0.4.16" + jwt_decode: + dependency: transitive + description: + name: jwt_decode + sha256: d2e9f68c052b2225130977429d30f187aa1981d789c76ad104a32243cfdebfbb + url: "https://pub.dev" + source: hosted + version: "0.3.1" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" + url: "https://pub.dev" + source: hosted + version: "10.0.7" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" + url: "https://pub.dev" + source: hosted + version: "3.0.8" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + markdown: + dependency: transitive + description: + name: markdown + sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" + url: "https://pub.dev" + source: hosted + version: "7.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + url: "https://pub.dev" + source: hosted + version: "0.12.16+1" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + url: "https://pub.dev" + source: hosted + version: "1.15.0" + mime: + dependency: transitive + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 + url: "https://pub.dev" + source: hosted + version: "2.1.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + passkeys_platform_interface: + dependency: transitive + description: + name: passkeys_platform_interface + sha256: "56dd98bbe45d79de87e8683d25741b2f61743542a39a85207b0a5b4bccff600e" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + path: + dependency: transitive + description: + name: path + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + url: "https://pub.dev" + source: hosted + version: "1.9.0" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 + url: "https://pub.dev" + source: hosted + version: "2.2.17" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + url: "https://pub.dev" + source: hosted + version: "6.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" + url: "https://pub.dev" + source: hosted + version: "3.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "4177f68c237ea2128d1bee66ac17b2ce05ba3dbaafcbdd54c5d40a39d0b6b11c" + url: "https://pub.dev" + source: hosted + version: "1.5.3" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" + postgrest: + dependency: transitive + description: + name: postgrest + sha256: "10e3f195e131eec944fa872644aed89b33b5be998f3fc77c87325db3927bc646" + url: "https://pub.dev" + source: hosted + version: "2.8.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + realtime_client: + dependency: transitive + description: + name: realtime_client + sha256: cf816d406248a6286bf7aa2053b406583ff88bd0842ab7f3f3127d53f6f1c68b + url: "https://pub.dev" + source: hosted + version: "2.10.0" + receive_sharing_intent: + dependency: "direct main" + description: + name: receive_sharing_intent + sha256: ec76056e4d258ad708e76d85591d933678625318e411564dcb9059048ca3a593 + url: "https://pub.dev" + source: hosted + version: "1.8.1" + retry: + dependency: transitive + description: + name: retry + sha256: "822e118d5b3aafed083109c72d5f484c6dc66707885e07c0fbcb8b986bba7efc" + url: "https://pub.dev" + source: hosted + version: "3.1.2" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + riverpod_analyzer_utils: + dependency: transitive + description: + name: riverpod_analyzer_utils + sha256: "03a17170088c63aab6c54c44456f5ab78876a1ddb6032ffde1662ddab4959611" + url: "https://pub.dev" + source: hosted + version: "0.5.10" + riverpod_annotation: + dependency: "direct main" + description: + name: riverpod_annotation + sha256: e14b0bf45b71326654e2705d462f21b958f987087be850afd60578fcd502d1b8 + url: "https://pub.dev" + source: hosted + version: "2.6.1" + riverpod_generator: + dependency: "direct dev" + description: + name: riverpod_generator + sha256: "44a0992d54473eb199ede00e2260bd3c262a86560e3c6f6374503d86d0580e36" + url: "https://pub.dev" + source: hosted + version: "2.6.5" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: "223873d106614442ea6f20db5a038685cc5b32a2fba81cdecaefbbae0523f7fa" + url: "https://pub.dev" + source: hosted + version: "12.0.2" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "5bcf0772a761b04f8c6bf814721713de6f3e5d9d89caf8d3fe031b02a342379e" + url: "https://pub.dev" + source: hosted + version: "2.4.11" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + shimmer: + dependency: "direct main" + description: + name: shimmer + sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca + url: "https://pub.dev" + source: hosted + version: "1.3.7" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: "2d7299468485dca85efeeadf5d38986909c5eb0cd71fd3db2c2f000e6c9454bb" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "78f489aab276260cdd26676d2169446c7ecd3484bbd5fead4ca14f3ed4dd9ee3" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "761b9740ecbd4d3e66b8916d784e581861fd3c3553eda85e167bc49fdb68f709" + url: "https://pub.dev" + source: hosted + version: "2.5.4+6" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "22adfd9a2c7d634041e96d6241e6e1c8138ca6817018afc5d443fef91dcefa9c" + url: "https://pub.dev" + source: hosted + version: "2.4.1+1" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" + url: "https://pub.dev" + source: hosted + version: "1.12.0" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + storage_client: + dependency: transitive + description: + name: storage_client + sha256: "221263cfbe0c01575b7d7fe7543e44abff380eb4d38d936372844a1caa85ba12" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.dev" + source: hosted + version: "2.1.2" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: a00e5f18bffc764f923e7dec1038527f7fe7a1791361a7117f0358193f13d53a + url: "https://pub.dev" + source: hosted + version: "2.1.2" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + supabase: + dependency: transitive + description: + name: supabase + sha256: "6933e60614652fa10749eb29fdcc6f2adb6515f5638e5ce28376ae08a626d745" + url: "https://pub.dev" + source: hosted + version: "2.13.4" + supabase_flutter: + dependency: "direct main" + description: + name: supabase_flutter + sha256: b6d34558565c14f1b44ff7bc9ca2979ed38ee2907b80ed73e699150cec08b739 + url: "https://pub.dev" + source: hosted + version: "2.15.4" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225" + url: "https://pub.dev" + source: hosted + version: "3.3.0+3" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" + url: "https://pub.dev" + source: hosted + version: "0.7.3" + timeago: + dependency: "direct main" + description: + name: timeago + sha256: b05159406a97e1cbb2b9ee4faa9fb096fe0e2dfcd8b08fcd2a00553450d3422e + url: "https://pub.dev" + source: hosted + version: "3.7.1" + timezone: + dependency: transitive + description: + name: timezone + sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "0aedad096a85b49df2e4725fa32118f9fa580f3b14af7a2d2221896a02cd5656" + url: "https://pub.dev" + source: hosted + version: "6.3.17" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb" + url: "https://pub.dev" + source: hosted + version: "6.3.3" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + url: "https://pub.dev" + source: hosted + version: "3.1.4" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b + url: "https://pub.dev" + source: hosted + version: "14.3.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webview_flutter: + dependency: "direct main" + description: + name: webview_flutter + sha256: c3e4fe614b1c814950ad07186007eff2f2e5dd2935eba7b9a9a1af8e5885f1ba + url: "https://pub.dev" + source: hosted + version: "4.13.0" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + sha256: "0a42444056b24ed832bdf3442d65c5194f6416f7e782152384944053c2ecc9a3" + url: "https://pub.dev" + source: hosted + version: "4.10.0" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0" + url: "https://pub.dev" + source: hosted + version: "2.14.0" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + sha256: fb46db8216131a3e55bcf44040ca808423539bc6732e7ed34fb6d8044e3d512f + url: "https://pub.dev" + source: hosted + version: "3.23.0" + win32: + dependency: transitive + description: + name: win32 + sha256: daf97c9d80197ed7b619040e86c8ab9a9dad285e7671ee7390f9180cc828a51e + url: "https://pub.dev" + source: hosted + version: "5.10.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" + yet_another_json_isolate: + dependency: transitive + description: + name: yet_another_json_isolate + sha256: eaa26beb5990b25a49d942374fd5a0c5aa67a837e03b14b4c26134aaa1ed01a9 + url: "https://pub.dev" + source: hosted + version: "2.1.1" +sdks: + dart: ">=3.6.0 <4.0.0" + flutter: ">=3.27.0" diff --git a/pubspec.yaml b/pubspec.yaml index 16ddc40..9a1e0fb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,14 +11,14 @@ dependencies: sdk: flutter # State Management - flutter_riverpod: ^2.4.9 - riverpod_annotation: ^2.3.3 + flutter_riverpod: ^2.6.1 + riverpod_annotation: ^2.6.1 # Navigation - go_router: ^13.2.0 + go_router: ^13.2.5 # Backend - supabase_flutter: ^2.3.4 + supabase_flutter: ^2.15.4 # Local Storage shared_preferences: ^2.2.2 @@ -32,7 +32,7 @@ dependencies: dio: ^5.4.0 # UI - flutter_markdown: ^0.6.18 + flutter_markdown: ^0.7.7 cached_network_image: ^3.3.1 shimmer: ^3.0.0 flutter_animate: ^4.3.0 @@ -47,8 +47,8 @@ dependencies: flutter_local_notifications: ^16.3.2 # Audio Playback - just_audio: ^0.9.36 - audio_session: ^0.1.25 + just_audio: ^0.10.6 + audio_session: ^0.2.4 # Sharing share_plus: ^12.0.1 @@ -56,15 +56,15 @@ dependencies: # Utils url_launcher: ^6.2.4 html: ^0.15.4 - intl: ^0.19.0 + intl: ^0.20.2 uuid: ^4.3.3 timeago: ^3.6.1 dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^3.0.1 - riverpod_generator: ^2.3.9 + flutter_lints: ^5.0.0 + riverpod_generator: ^2.6.5 build_runner: ^2.4.8 json_serializable: ^6.7.1 flutter_launcher_icons: ^0.14.3 diff --git a/supabase/functions/.env.example b/supabase/functions/.env.example new file mode 100644 index 0000000..6f38f9c --- /dev/null +++ b/supabase/functions/.env.example @@ -0,0 +1,20 @@ +# Secrets for the Supabase Edge Functions (extract-article, generate-digest, +# process-queue). Copy to supabase/functions/.env for `supabase functions serve`, +# or push to the hosted project with: +# supabase secrets set --env-file supabase/functions/.env +# +# SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are injected automatically by the +# Supabase runtime and must not be set here. + +# Required +OPENROUTER_API_KEY= # Claude via OpenRouter (article analysis, digests) +JINA_API_KEY= # Jina Reader fallback extraction +XAI_API_KEY= # Grok, X/Twitter post extraction + +# Optional +TAVILY_API_KEY= # Search enrichment +UNSPLASH_ACCESS_KEY= # Fallback cover images + +# Model ids (defaults shown; override without redeploying) +CLAUDE_MODEL=anthropic/claude-haiku-4.5 # OpenRouter slug +GROK_MODEL=grok-4-1-fast # xAI model id diff --git a/supabase/functions/_shared/config.ts b/supabase/functions/_shared/config.ts new file mode 100644 index 0000000..c793694 --- /dev/null +++ b/supabase/functions/_shared/config.ts @@ -0,0 +1,16 @@ +// Shared runtime configuration for the Edge Functions. +// Model ids can be overridden with `supabase secrets set` without redeploying. + +/** OpenRouter slug for the Claude model used for analysis and digests. */ +export const DEFAULT_CLAUDE_MODEL = "anthropic/claude-haiku-4.5"; + +/** xAI model id used for X/Twitter post extraction. */ +export const DEFAULT_GROK_MODEL = "grok-4-1-fast"; + +function envOrDefault(name: string, fallback: string): string { + const value = Deno.env.get(name)?.trim(); + return value ? value : fallback; +} + +export const CLAUDE_MODEL = envOrDefault("CLAUDE_MODEL", DEFAULT_CLAUDE_MODEL); +export const GROK_MODEL = envOrDefault("GROK_MODEL", DEFAULT_GROK_MODEL); diff --git a/supabase/functions/extract-article/index.ts b/supabase/functions/extract-article/index.ts index 9b3aba3..ccd420a 100644 --- a/supabase/functions/extract-article/index.ts +++ b/supabase/functions/extract-article/index.ts @@ -6,6 +6,7 @@ import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; import { Readability } from "npm:@mozilla/readability@0.6.0"; import { parseHTML } from "npm:linkedom@0.18.9"; +import { CLAUDE_MODEL, GROK_MODEL } from "../_shared/config.ts"; const JINA_API_KEY = Deno.env.get("JINA_API_KEY"); const OPENROUTER_API_KEY = Deno.env.get("OPENROUTER_API_KEY"); @@ -15,8 +16,8 @@ const UNSPLASH_ACCESS_KEY = Deno.env.get("UNSPLASH_ACCESS_KEY"); const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!; const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; -// Using Claude Haiku 3.5 via OpenRouter for cost efficiency -const LLM_MODEL = "anthropic/claude-3.5-haiku"; +// Claude via OpenRouter (override with the CLAUDE_MODEL secret) +const LLM_MODEL = CLAUDE_MODEL; const corsHeaders = { "Access-Control-Allow-Origin": "*", @@ -126,7 +127,7 @@ function cleanTitle(title: string, url: string): string { // Remove " | Site Name" suffixes .replace(/\s*\|\s*[^|]{1,50}$/, '') // Remove " - Site Name" suffixes - .replace(/\s*[-–—]\s*[^-–—]{1,50}$/, '') + .replace(/\s*[-–-]\s*[^-–-]{1,50}$/, '') // Remove Reddit-style metadata "r/subreddit" .replace(/\s*:\s*r\/\w+\s*$/, '') // Remove comment counts "(123 comments)" @@ -864,7 +865,7 @@ async function extractWithGrok(url: string): Promise { console.log(`[Grok] Parsed: @${username}, ID: ${postId}`); const requestBody = { - model: "grok-4-1-fast", + model: GROK_MODEL, tools: [ { type: "x_search", diff --git a/supabase/functions/generate-digest/index.ts b/supabase/functions/generate-digest/index.ts index 4034254..c4580cd 100644 --- a/supabase/functions/generate-digest/index.ts +++ b/supabase/functions/generate-digest/index.ts @@ -3,13 +3,14 @@ import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { CLAUDE_MODEL } from "../_shared/config.ts"; const OPENROUTER_API_KEY = Deno.env.get("OPENROUTER_API_KEY"); const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!; const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; -// Using Claude Haiku 4.5 via OpenRouter for cost efficiency -const LLM_MODEL = "anthropic/claude-3.5-haiku"; +// Claude via OpenRouter (override with the CLAUDE_MODEL secret) +const LLM_MODEL = CLAUDE_MODEL; const corsHeaders = { "Access-Control-Allow-Origin": "*", diff --git a/supabase/functions/test-grok/index.ts b/supabase/functions/test-grok/index.ts deleted file mode 100644 index c4abdb8..0000000 --- a/supabase/functions/test-grok/index.ts +++ /dev/null @@ -1,103 +0,0 @@ -// Test function to debug Grok extraction -import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; - -const XAI_API_KEY = Deno.env.get("XAI_API_KEY"); - -serve(async (req) => { - const corsHeaders = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", - }; - - if (req.method === "OPTIONS") { - return new Response("ok", { headers: corsHeaders }); - } - - try { - const { url } = await req.json(); - - console.log("[test-grok] Starting test"); - console.log("[test-grok] XAI_API_KEY set:", !!XAI_API_KEY); - console.log("[test-grok] XAI_API_KEY length:", XAI_API_KEY?.length || 0); - console.log("[test-grok] URL:", url); - - if (!XAI_API_KEY) { - return new Response( - JSON.stringify({ error: "XAI_API_KEY not set" }), - { headers: { ...corsHeaders, "Content-Type": "application/json" } } - ); - } - - const urlMatch = url.match(/(?:twitter\.com|x\.com)\/([^\/]+)\/status\/(\d+)/); - if (!urlMatch) { - return new Response( - JSON.stringify({ error: "Could not parse URL", url }), - { headers: { ...corsHeaders, "Content-Type": "application/json" } } - ); - } - - const username = urlMatch[1]; - const postId = urlMatch[2]; - console.log("[test-grok] Username:", username, "PostID:", postId); - - const response = await fetch("https://api.x.ai/v1/responses", { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${XAI_API_KEY}`, - }, - body: JSON.stringify({ - model: "grok-4-1-fast", - tools: [{ type: "x_search", x_search: { allowed_x_handles: [username] } }], - input: [{ role: "user", content: `Extract this X post: ${url}` }], - }), - }); - - console.log("[test-grok] Response status:", response.status); - - if (!response.ok) { - const errorText = await response.text(); - console.error("[test-grok] API error:", errorText); - return new Response( - JSON.stringify({ error: `API error: ${response.status}`, details: errorText }), - { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } - ); - } - - const result = await response.json(); - console.log("[test-grok] Got response, status:", result.status); - - // Extract text from response - let outputText = ""; - if (result.output && Array.isArray(result.output)) { - for (const item of result.output) { - if (item.type === "message" && item.role === "assistant" && item.content) { - for (const c of item.content) { - if (c.type === "output_text" && c.text) { - outputText = c.text; - break; - } - } - } - } - } - - return new Response( - JSON.stringify({ - success: true, - username, - postId, - outputLength: outputText.length, - outputPreview: outputText.slice(0, 500), - fullResponse: result, - }), - { headers: { ...corsHeaders, "Content-Type": "application/json" } } - ); - } catch (error: any) { - console.error("[test-grok] Error:", error); - return new Response( - JSON.stringify({ error: error.message, stack: error.stack }), - { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } - ); - } -}); diff --git a/supabase/migrations/001_initial_schema.sql b/supabase/migrations/001_initial_schema.sql deleted file mode 100644 index 4e2f4ef..0000000 --- a/supabase/migrations/001_initial_schema.sql +++ /dev/null @@ -1,233 +0,0 @@ --- Readwise Database Schema --- Run this in your Supabase SQL editor - --- Enable required extensions -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -CREATE EXTENSION IF NOT EXISTS pg_cron; - --- ============================================ --- TABLES --- ============================================ - --- Users table (extends Supabase auth) -CREATE TABLE IF NOT EXISTS public.user_settings ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE UNIQUE NOT NULL, - digest_time TIME DEFAULT '08:00:00', - timezone TEXT DEFAULT 'America/Los_Angeles', - analyze_images BOOLEAN DEFAULT true, - include_comments BOOLEAN DEFAULT true, - push_notifications BOOLEAN DEFAULT true, - fcm_token TEXT, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() -); - --- Articles table -CREATE TABLE IF NOT EXISTS public.articles ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL, - url TEXT NOT NULL, - title TEXT, - description TEXT, - content TEXT, -- Extracted markdown content - image_url TEXT, - site_name TEXT, - author TEXT, - images JSONB DEFAULT '[]'::jsonb, -- Array of {url, alt, caption, ai_description} - comments JSONB DEFAULT '[]'::jsonb, -- Array of comment objects - analysis JSONB, -- AI analysis results - status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'extracting', 'analyzing', 'ready', 'failed')), - error_message TEXT, - created_at TIMESTAMPTZ DEFAULT NOW(), - read_at TIMESTAMPTZ, - is_archived BOOLEAN DEFAULT false, - - -- Unique constraint to prevent duplicate URLs per user - UNIQUE(user_id, url) -); - --- Daily digests table -CREATE TABLE IF NOT EXISTS public.digests ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL, - date DATE NOT NULL, - overall_summary TEXT NOT NULL, - top_themes TEXT[] DEFAULT '{}', - articles JSONB NOT NULL DEFAULT '[]'::jsonb, -- Array of digest article summaries - ai_insights TEXT, -- Cross-article insights - created_at TIMESTAMPTZ DEFAULT NOW(), - is_read BOOLEAN DEFAULT false, - - -- One digest per user per day - UNIQUE(user_id, date) -); - --- Processing queue for background jobs -CREATE TABLE IF NOT EXISTS public.processing_queue ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - article_id UUID REFERENCES public.articles(id) ON DELETE CASCADE NOT NULL, - job_type TEXT NOT NULL CHECK (job_type IN ('extract', 'analyze')), - status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'completed', 'failed')), - attempts INTEGER DEFAULT 0, - last_error TEXT, - created_at TIMESTAMPTZ DEFAULT NOW(), - started_at TIMESTAMPTZ, - completed_at TIMESTAMPTZ -); - --- ============================================ --- INDEXES --- ============================================ - -CREATE INDEX IF NOT EXISTS idx_articles_user_id ON public.articles(user_id); -CREATE INDEX IF NOT EXISTS idx_articles_created_at ON public.articles(created_at DESC); -CREATE INDEX IF NOT EXISTS idx_articles_status ON public.articles(status); -CREATE INDEX IF NOT EXISTS idx_digests_user_id ON public.digests(user_id); -CREATE INDEX IF NOT EXISTS idx_digests_date ON public.digests(date DESC); -CREATE INDEX IF NOT EXISTS idx_processing_queue_status ON public.processing_queue(status); - --- ============================================ --- ROW LEVEL SECURITY --- ============================================ - -ALTER TABLE public.user_settings ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.articles ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.digests ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.processing_queue ENABLE ROW LEVEL SECURITY; - --- User settings policies -CREATE POLICY "Users can view own settings" - ON public.user_settings FOR SELECT - USING (auth.uid() = user_id); - -CREATE POLICY "Users can insert own settings" - ON public.user_settings FOR INSERT - WITH CHECK (auth.uid() = user_id); - -CREATE POLICY "Users can update own settings" - ON public.user_settings FOR UPDATE - USING (auth.uid() = user_id); - --- Articles policies -CREATE POLICY "Users can view own articles" - ON public.articles FOR SELECT - USING (auth.uid() = user_id); - -CREATE POLICY "Users can insert own articles" - ON public.articles FOR INSERT - WITH CHECK (auth.uid() = user_id); - -CREATE POLICY "Users can update own articles" - ON public.articles FOR UPDATE - USING (auth.uid() = user_id); - -CREATE POLICY "Users can delete own articles" - ON public.articles FOR DELETE - USING (auth.uid() = user_id); - --- Digests policies -CREATE POLICY "Users can view own digests" - ON public.digests FOR SELECT - USING (auth.uid() = user_id); - -CREATE POLICY "Users can update own digests" - ON public.digests FOR UPDATE - USING (auth.uid() = user_id); - --- Service role policy for processing queue (only accessible by backend) -CREATE POLICY "Service role can manage processing queue" - ON public.processing_queue - USING (true) - WITH CHECK (true); - --- ============================================ --- FUNCTIONS --- ============================================ - --- Function to create user settings on signup -CREATE OR REPLACE FUNCTION public.handle_new_user() -RETURNS TRIGGER AS $$ -BEGIN - INSERT INTO public.user_settings (user_id) - VALUES (NEW.id); - RETURN NEW; -END; -$$ LANGUAGE plpgsql SECURITY DEFINER; - --- Trigger for new user signup -DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users; -CREATE TRIGGER on_auth_user_created - AFTER INSERT ON auth.users - FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); - --- Function to queue article for processing -CREATE OR REPLACE FUNCTION public.queue_article_processing() -RETURNS TRIGGER AS $$ -BEGIN - INSERT INTO public.processing_queue (article_id, job_type) - VALUES (NEW.id, 'extract'); - RETURN NEW; -END; -$$ LANGUAGE plpgsql SECURITY DEFINER; - --- Trigger to auto-queue new articles -DROP TRIGGER IF EXISTS on_article_created ON public.articles; -CREATE TRIGGER on_article_created - AFTER INSERT ON public.articles - FOR EACH ROW EXECUTE FUNCTION public.queue_article_processing(); - --- Function to update article status -CREATE OR REPLACE FUNCTION public.update_article_status( - p_article_id UUID, - p_status TEXT, - p_error_message TEXT DEFAULT NULL -) -RETURNS void AS $$ -BEGIN - UPDATE public.articles - SET - status = p_status, - error_message = p_error_message - WHERE id = p_article_id; -END; -$$ LANGUAGE plpgsql SECURITY DEFINER; - --- ============================================ --- SCHEDULED JOBS (pg_cron) --- ============================================ - --- Schedule daily digest generation at 8 AM UTC (adjust for your timezone) --- This calls a Supabase Edge Function -SELECT cron.schedule( - 'generate-daily-digests', - '0 8 * * *', -- Every day at 8:00 AM UTC - $$ - SELECT net.http_post( - url := 'https://YOUR_PROJECT_REF.supabase.co/functions/v1/generate-digest', - headers := '{"Content-Type": "application/json", "Authorization": "Bearer YOUR_SERVICE_ROLE_KEY"}'::jsonb, - body := '{}'::jsonb - ); - $$ -); - --- Schedule processing queue worker every minute -SELECT cron.schedule( - 'process-article-queue', - '* * * * *', -- Every minute - $$ - SELECT net.http_post( - url := 'https://YOUR_PROJECT_REF.supabase.co/functions/v1/process-queue', - headers := '{"Content-Type": "application/json", "Authorization": "Bearer YOUR_SERVICE_ROLE_KEY"}'::jsonb, - body := '{}'::jsonb - ); - $$ -); - --- ============================================ --- REALTIME --- ============================================ - --- Enable realtime for articles and digests -ALTER PUBLICATION supabase_realtime ADD TABLE public.articles; -ALTER PUBLICATION supabase_realtime ADD TABLE public.digests; diff --git a/supabase/migrations/20250130_processing_queue.sql b/supabase/migrations/20250130_processing_queue.sql new file mode 100644 index 0000000..26e8527 --- /dev/null +++ b/supabase/migrations/20250130_processing_queue.sql @@ -0,0 +1,92 @@ +-- Processing queue, triggers and helper functions used by the +-- process-queue and extract-article Edge Functions. +-- +-- These objects were previously defined only in the legacy +-- 001_initial_schema.sql (removed). They are kept here so a fresh +-- `supabase db push` produces a schema the Edge Functions can run against. +-- Every statement is idempotent, so applying it to a database that already +-- has these objects is safe. + +-- ============================================ +-- ARTICLES: error_message column +-- ============================================ + +ALTER TABLE public.articles ADD COLUMN IF NOT EXISTS error_message TEXT; + +-- ============================================ +-- PROCESSING QUEUE +-- ============================================ + +CREATE TABLE IF NOT EXISTS public.processing_queue ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + article_id UUID REFERENCES public.articles(id) ON DELETE CASCADE NOT NULL, + job_type TEXT NOT NULL CHECK (job_type IN ('extract', 'analyze')), + status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'completed', 'failed')), + attempts INTEGER DEFAULT 0, + last_error TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_processing_queue_status ON public.processing_queue(status); + +ALTER TABLE public.processing_queue ENABLE ROW LEVEL SECURITY; + +-- Only the service role (Edge Functions) touches the queue. +DROP POLICY IF EXISTS "Service role can manage processing queue" ON public.processing_queue; +CREATE POLICY "Service role can manage processing queue" + ON public.processing_queue + USING (auth.jwt()->>'role' = 'service_role') + WITH CHECK (auth.jwt()->>'role' = 'service_role'); + +-- ============================================ +-- FUNCTIONS AND TRIGGERS +-- ============================================ + +-- Create a user_settings row for every new auth user +CREATE OR REPLACE FUNCTION public.handle_new_user() +RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO public.user_settings (user_id) + VALUES (NEW.id) + ON CONFLICT (user_id) DO NOTHING; + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users; +CREATE TRIGGER on_auth_user_created + AFTER INSERT ON auth.users + FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); + +-- Queue every newly saved article for extraction +CREATE OR REPLACE FUNCTION public.queue_article_processing() +RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO public.processing_queue (article_id, job_type) + VALUES (NEW.id, 'extract'); + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +DROP TRIGGER IF EXISTS on_article_created ON public.articles; +CREATE TRIGGER on_article_created + AFTER INSERT ON public.articles + FOR EACH ROW EXECUTE FUNCTION public.queue_article_processing(); + +-- Helper used by backend jobs to record article status transitions +CREATE OR REPLACE FUNCTION public.update_article_status( + p_article_id UUID, + p_status TEXT, + p_error_message TEXT DEFAULT NULL +) +RETURNS void AS $$ +BEGIN + UPDATE public.articles + SET + status = p_status, + error_message = p_error_message + WHERE id = p_article_id; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/supabase/migrations/20260205_setup_daily_digest_cron.sql b/supabase/migrations/20260205_setup_daily_digest_cron.sql index d05d29e..c9d0906 100644 --- a/supabase/migrations/20260205_setup_daily_digest_cron.sql +++ b/supabase/migrations/20260205_setup_daily_digest_cron.sql @@ -15,6 +15,15 @@ GRANT USAGE ON SCHEMA cron TO postgres; -- ============================================ -- Runs every day at 8 AM UTC -- Calls the generate-digest Edge Function +-- +-- Before running: replace with the project URL +-- (https://.supabase.co) and with the +-- project's anon key. The key is intentionally not committed here so that it +-- is never baked into the repository or the cron job definition by accident. +-- To avoid storing the key in cron.job at all, store it in Supabase Vault +-- (select vault.create_secret('', 'supabase_anon_key')) and build the +-- header from (select decrypted_secret from vault.decrypted_secrets +-- where name = 'supabase_anon_key') instead of the literal below. -- First, remove any existing job with the same name SELECT cron.unschedule('generate-daily-digests') WHERE EXISTS ( @@ -27,10 +36,10 @@ SELECT cron.schedule( '0 8 * * *', -- Every day at 8:00 AM UTC $$ SELECT net.http_post( - url := 'https://bioiacixxauufpvswlxe.supabase.co/functions/v1/generate-digest', + url := '/functions/v1/generate-digest', headers := jsonb_build_object( 'Content-Type', 'application/json', - 'Authorization', 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImJpb2lhY2l4eGF1dWZwdnN3bHhlIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njk2OTc2MzAsImV4cCI6MjA4NTI3MzYzMH0.rALzbiI5ggIQYOrcPC8VeRVyJO_0KNVtQCaVKz7d9cQ' + 'Authorization', 'Bearer ' ), body := '{}'::jsonb ); diff --git a/test/core/config/env_test.dart b/test/core/config/env_test.dart new file mode 100644 index 0000000..78c98c4 --- /dev/null +++ b/test/core/config/env_test.dart @@ -0,0 +1,28 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:readzero/core/config/env.dart'; + +// `flutter test` runs without --dart-define, so both Supabase values are +// empty here. The tests pin down the behaviour of an unconfigured build. +void main() { + test('an unconfigured build reports both missing defines', () { + expect(Env.isConfigured, isFalse); + expect(Env.missingDefines, ['SUPABASE_URL', 'SUPABASE_ANON_KEY']); + expect(Env.missingConfigMessage, contains('--dart-define-from-file=.env')); + }); + + test('ensureConfigured throws a StateError naming the defines', () { + expect( + Env.ensureConfigured, + throwsA(isA().having( + (e) => e.message, + 'message', + allOf(contains('SUPABASE_URL'), contains('SUPABASE_ANON_KEY')), + )), + ); + }); + + test('legal URLs point at the GitHub Pages site', () { + expect(Env.privacyPolicyUrl, startsWith('https://bluzername.github.io/ReadZero/')); + expect(Env.termsOfServiceUrl, startsWith('https://bluzername.github.io/ReadZero/')); + }); +} diff --git a/test/core/models/article_test.dart b/test/core/models/article_test.dart new file mode 100644 index 0000000..bfd5822 --- /dev/null +++ b/test/core/models/article_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:readzero/core/models/models.dart'; + +void main() { + const minimalJson = { + 'id': 'a1', + 'user_id': 'u1', + 'url': 'https://example.com/post', + 'created_at': '2026-01-29T10:00:00Z', + }; + + group('Article.fromJson', () { + test('applies defaults for optional fields', () { + final article = Article.fromJson(Map.from(minimalJson)); + + expect(article.id, 'a1'); + expect(article.userId, 'u1'); + expect(article.url, 'https://example.com/post'); + expect(article.status, ArticleStatus.pending); + expect(article.images, isEmpty); + expect(article.comments, isEmpty); + expect(article.analysis, isNull); + expect(article.readAt, isNull); + expect(article.isArchived, isFalse); + expect(article.createdAt, DateTime.utc(2026, 1, 29, 10)); + }); + + test('maps known status names and falls back to pending', () { + final ready = Article.fromJson({...minimalJson, 'status': 'ready'}); + final bogus = Article.fromJson({...minimalJson, 'status': 'nope'}); + + expect(ready.status, ArticleStatus.ready); + expect(bogus.status, ArticleStatus.pending); + }); + + test('parses nested analysis and image credit', () { + final article = Article.fromJson({ + ...minimalJson, + 'status': 'ready', + 'image_credit': { + 'name': 'Annie', + 'username': 'annie', + 'profile_url': 'https://unsplash.com/@annie', + 'photo_url': 'https://unsplash.com/photos/abc', + }, + 'analysis': { + 'summary': 'Short summary', + 'key_points': ['one', 'two'], + 'topics': ['tech'], + 'reading_time_minutes': 4, + 'relevance_score': 1, + }, + }); + + expect(article.imageCredit?.name, 'Annie'); + expect(article.analysis?.keyPoints, ['one', 'two']); + expect(article.analysis?.readingTimeMinutes, 4); + expect(article.analysis?.relevanceScore, 1.0); + }); + + test('toJson round-trips through fromJson', () { + final original = Article.fromJson({ + ...minimalJson, + 'title': 'Hello', + 'status': 'analyzing', + 'read_at': '2026-02-01T08:30:00Z', + 'is_archived': true, + }); + + final copy = Article.fromJson(original.toJson()); + + expect(copy.title, 'Hello'); + expect(copy.status, ArticleStatus.analyzing); + expect(copy.readAt, original.readAt); + expect(copy.isArchived, isTrue); + }); + }); + + group('ArticleAnalysis.fromJson', () { + test('substitutes a placeholder when summary is missing', () { + final analysis = ArticleAnalysis.fromJson(const {}); + + expect(analysis.summary, 'Summary unavailable'); + expect(analysis.keyPoints, isEmpty); + expect(analysis.detailedPoints, isEmpty); + expect(analysis.topics, isEmpty); + expect(analysis.imageAnalyses, isEmpty); + }); + }); +} diff --git a/test/core/models/daily_digest_test.dart b/test/core/models/daily_digest_test.dart new file mode 100644 index 0000000..3f4051a --- /dev/null +++ b/test/core/models/daily_digest_test.dart @@ -0,0 +1,38 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:readzero/core/models/models.dart'; + +void main() { + group('DailyDigest', () { + final json = { + 'id': 'd1', + 'user_id': 'u1', + 'date': '2026-02-05', + 'overall_summary': 'Three articles about AI.', + 'top_themes': ['ai', 'tooling'], + 'articles': >[], + 'ai_insights': 'They all mention agents.', + 'created_at': '2026-02-05T08:00:00Z', + }; + + test('fromJson parses fields and defaults is_read to false', () { + final digest = DailyDigest.fromJson(json); + + expect(digest.id, 'd1'); + expect(digest.date, DateTime(2026, 2, 5)); + expect(digest.topThemes, ['ai', 'tooling']); + expect(digest.articles, isEmpty); + expect(digest.aiInsights, 'They all mention agents.'); + expect(digest.isRead, isFalse); + }); + + test('toJson round-trips', () { + final digest = DailyDigest.fromJson({...json, 'is_read': true}); + final copy = DailyDigest.fromJson(digest.toJson()); + + expect(copy.overallSummary, digest.overallSummary); + expect(copy.topThemes, digest.topThemes); + expect(copy.createdAt, digest.createdAt); + expect(copy.isRead, isTrue); + }); + }); +} diff --git a/test/core/models/podcast_episode_test.dart b/test/core/models/podcast_episode_test.dart new file mode 100644 index 0000000..344dc4c --- /dev/null +++ b/test/core/models/podcast_episode_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:readzero/core/models/models.dart'; + +PodcastEpisode _episode({ + String status = 'pending', + String? audioUrl, + int? durationSeconds, +}) { + return PodcastEpisode( + id: 'p1', + userId: 'u1', + title: 'Digest for Feb 5', + status: status, + audioUrl: audioUrl, + durationSeconds: durationSeconds, + publishedAt: DateTime.utc(2026, 2, 5), + createdAt: DateTime.utc(2026, 2, 5), + ); +} + +void main() { + group('PodcastEpisode.formattedDuration', () { + test('is empty when duration is unknown', () { + expect(_episode().formattedDuration, ''); + }); + + test('formats minutes and zero-padded seconds', () { + expect(_episode(durationSeconds: 65).formattedDuration, '1:05'); + expect(_episode(durationSeconds: 600).formattedDuration, '10:00'); + expect(_episode(durationSeconds: 7).formattedDuration, '0:07'); + }); + }); + + group('PodcastEpisode status flags', () { + test('isReady requires ready status and an audio url', () { + expect(_episode(status: 'ready').isReady, isFalse); + expect( + _episode(status: 'ready', audioUrl: 'https://cdn/x.mp3').isReady, + isTrue, + ); + }); + + test('isGenerating covers pending and generating', () { + expect(_episode(status: 'pending').isGenerating, isTrue); + expect(_episode(status: 'generating').isGenerating, isTrue); + expect(_episode(status: 'failed').isGenerating, isFalse); + expect(_episode(status: 'failed').isFailed, isTrue); + }); + }); + + test('fromJson defaults status to pending', () { + final episode = PodcastEpisode.fromJson({ + 'id': 'p1', + 'user_id': 'u1', + 'title': 'T', + 'published_at': '2026-02-05T00:00:00Z', + 'created_at': '2026-02-05T00:00:00Z', + }); + + expect(episode.status, 'pending'); + expect(episode.durationSeconds, isNull); + }); +} diff --git a/test/core/models/user_settings_test.dart b/test/core/models/user_settings_test.dart new file mode 100644 index 0000000..473db89 --- /dev/null +++ b/test/core/models/user_settings_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:readzero/core/models/models.dart'; + +void main() { + const base = {'id': 's1', 'user_id': 'u1'}; + + group('UserSettings.fromJson', () { + test('parses digest_time and applies defaults', () { + final settings = UserSettings.fromJson({...base, 'digest_time': '07:45:00'}); + + expect(settings.digestTime, const TimeOfDay(hour: 7, minute: 45)); + expect(settings.timezone, 'America/Los_Angeles'); + expect(settings.analyzeImages, isTrue); + expect(settings.includeComments, isTrue); + expect(settings.pushNotifications, isTrue); + expect(settings.podcastToken, isNull); + }); + + test('defaults digest_time to 08:00 when absent', () { + final settings = UserSettings.fromJson(Map.from(base)); + + expect(settings.digestTime, const TimeOfDay(hour: 8, minute: 0)); + }); + }); + + group('UserSettings.toJson', () { + test('zero-pads digest_time as HH:MM:00', () { + final settings = UserSettings.fromJson({...base, 'digest_time': '06:05:00'}); + + expect(settings.toJson()['digest_time'], '06:05:00'); + }); + }); + + group('UserSettings.podcastFeedUrl', () { + test('is null without a podcast token', () { + expect(UserSettings.fromJson(Map.from(base)).podcastFeedUrl, isNull); + }); + + test('points at the podcast-feed function with the token', () { + final settings = UserSettings.fromJson({...base, 'podcast_token': 'tok123'}); + + expect( + settings.podcastFeedUrl, + endsWith('/functions/v1/podcast-feed?token=tok123'), + ); + }); + }); +} diff --git a/test/core/services/restricted_domains_test.dart b/test/core/services/restricted_domains_test.dart new file mode 100644 index 0000000..5ed5c01 --- /dev/null +++ b/test/core/services/restricted_domains_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:readzero/core/services/auth_extraction_service.dart'; + +void main() { + group('RestrictedDomains.getProvider', () { + test('detects LinkedIn and X/Twitter hosts', () { + expect(RestrictedDomains.getProvider('https://www.linkedin.com/in/x'), 'linkedin'); + expect(RestrictedDomains.getProvider('https://linkedin.com/posts/1'), 'linkedin'); + expect(RestrictedDomains.getProvider('https://x.com/user/status/1'), 'twitter'); + expect(RestrictedDomains.getProvider('https://twitter.com/user/status/1'), 'twitter'); + }); + + test('is case-insensitive on the host', () { + expect(RestrictedDomains.getProvider('https://X.com/a/status/2'), 'twitter'); + }); + + test('returns null for other sites and unparseable input', () { + expect(RestrictedDomains.getProvider('https://example.com/article'), isNull); + expect(RestrictedDomains.getProvider('not a url'), isNull); + expect(RestrictedDomains.getProvider(''), isNull); + }); + }); + + test('isRestricted mirrors getProvider', () { + expect(RestrictedDomains.isRestricted('https://x.com/a/status/1'), isTrue); + expect(RestrictedDomains.isRestricted('https://example.com'), isFalse); + }); + + group('RestrictedDomains.getLoginUrl', () { + test('returns provider login pages', () { + expect(RestrictedDomains.getLoginUrl('linkedin'), 'https://www.linkedin.com/login'); + expect(RestrictedDomains.getLoginUrl('twitter'), 'https://twitter.com/i/flow/login'); + }); + + test('throws for unknown providers', () { + expect(() => RestrictedDomains.getLoginUrl('mastodon'), throwsArgumentError); + }); + }); + + test('getProviderName returns display names and passes unknowns through', () { + expect(RestrictedDomains.getProviderName('linkedin'), 'LinkedIn'); + expect(RestrictedDomains.getProviderName('twitter'), 'X (Twitter)'); + expect(RestrictedDomains.getProviderName('other'), 'other'); + }); +}