diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..b58e81b --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,1127 @@ +# MyApp - Flutter Project Guide + +> Personal life management app with modular "Spaces" for recipes, expenses, photos, media tracking, and more. + +--- + +## Project Overview + +| Aspect | Details | +|--------|---------| +| **Tech Stack** | Flutter 3.6+, Dart, Supabase (auth + database), Provider | +| **Platforms** | iOS & Android | +| **Architecture** | MVC-inspired with Provider state management | +| **Current Features** | Email/Google auth, Books CRUD, Word Pair Generator | + +--- + +## Current Folder Structure + +``` +lib/ +├── main.dart # App entry, Supabase init, Provider setup +├── controller/ +│ ├── auth_service.dart # Authentication operations +│ ├── book_service.dart # Books CRUD via Supabase +│ ├── navigation.dart # Navigation singleton +│ ├── auth_gate.dart # Auth state routing +│ └── pages/ # Page-level logic (login, signup, profile) +├── model/ +│ ├── main_state.dart # Global app state (ChangeNotifier) +│ ├── menu.dart # Navigation state +│ ├── book.dart # Book data model +│ └── wordpair/ # Word pair state +└── view/ + ├── menu.dart # Main scaffold + ├── auth/ # Login, signup, profile UI + ├── book/ # Books page and dialogs + ├── wordpair/ # Generator and favorites + ├── components/ # Reusable widgets + └── navigation/ # Nav bar and rail widgets +``` + +--- + +## Immediate Improvements Needed + +Before adding new features, address these issues: + +### 1. Consistent Service Injection +**Problem**: Services created with `AuthService()` in pages instead of Provider. + +```dart +// BAD - creates new instance each time +final authService = AuthService(); + +// GOOD - use Provider +final authService = Provider.of(context, listen: false); +// or +final authService = context.read(); +``` + +### 2. Separate Business Logic from UI +**Problem**: Pages like `BooksPage` handle fetching and state directly. + +**Solution**: Create dedicated controllers/blocs per feature. + +### 3. Error Handling Layer +**Problem**: Ad-hoc try/catch scattered everywhere. + +**Solution**: Create custom exception classes: +```dart +abstract class AppException implements Exception { + final String message; + AppException(this.message); +} + +class AuthException extends AppException { + AuthException(super.message); +} + +class NetworkException extends AppException { + NetworkException(super.message); +} +``` + +### 4. Navigation Upgrade +**Problem**: Basic `Navigator.push` doesn't scale. + +**Solution**: Migrate to `go_router` for: +- Typed routes +- Deep linking +- URL sync +- Route guards + +### 5. Test Coverage +**Problem**: Almost no tests exist. + +**Priority**: Add unit tests for `AuthService`, `BookService`, state classes. + +### 6. Constants File +**Problem**: Magic strings throughout code. + +**Solution**: Create `lib/core/constants.dart`: +```dart +class AppConstants { + static const String booksTable = 'books'; + static const Duration animationDuration = Duration(milliseconds: 300); +} +``` + +--- + +## Vision & Roadmap + +See [WIP.md](../WIP.md) for full feature specs. + +### Core Concept: Spaces +Modular containers users create from templates: +- **Recipe Book** - Recipes with smart ingredient scaling +- **Expense Tracker** - Spending by category/trip with reports +- **Photo Album** - Shared collections with comments +- **Media Tracker** - Movies, books, games with progress +- **Notes** - Markdown notes and checklists +- **Custom** - User-defined field types + +### Collaboration Model +``` +Private → Shared (specific users) → Group (team access) +``` + +### Milestones +1. **MVP v0.1**: Auth + Spaces + Recipe Book + Basic offline +2. **v0.2**: Expense Tracker + Media Tracker +3. **v0.3**: Groups + Sharing + Real-time sync +4. **v0.4**: Chat + Push notifications +5. **v0.5**: Custom Space builder + Themes + +--- + +## Target Architecture + +Evolve toward this feature-based structure: + +``` +lib/ +├── core/ +│ ├── config/ # Environment, constants +│ ├── theme/ # ThemeData, colors, typography +│ ├── router/ # GoRouter configuration +│ └── utils/ # Extensions, helpers +├── data/ +│ ├── models/ # Data classes (User, Space, Recipe) +│ ├── repositories/ # Data access abstraction +│ ├── providers/ # Supabase, local storage +│ └── services/ # Business logic +├── features/ +│ ├── auth/ +│ ├── spaces/ +│ ├── recipes/ +│ ├── expenses/ +│ └── settings/ +└── shared/ + ├── widgets/ # Reusable components + └── layouts/ # Common screen layouts +``` + +--- + +## Flutter Learning Path + +### 1. Widget Fundamentals + +#### Everything is a Widget +Flutter uses composition: small widgets combine to build complex UIs. + +```dart +// Composition example +Widget build(BuildContext context) { + return Container( // Layout widget + padding: EdgeInsets.all(16), + child: Column( // Arranges children vertically + children: [ + Text('Hello'), // Display widget + ElevatedButton( // Interactive widget + onPressed: () {}, + child: Text('Tap'), + ), + ], + ), + ); +} +``` + +#### StatelessWidget vs StatefulWidget + +| StatelessWidget | StatefulWidget | +|-----------------|----------------| +| Immutable | Has mutable state | +| Rebuilt when parent rebuilds | Rebuilt via `setState()` | +| Use for: static content | Use for: interactive content | + +```dart +// StatelessWidget - no internal state +class Greeting extends StatelessWidget { + final String name; + const Greeting({super.key, required this.name}); + + @override + Widget build(BuildContext context) { + return Text('Hello, $name'); + } +} + +// StatefulWidget - has internal state +class Counter extends StatefulWidget { + const Counter({super.key}); + + @override + State createState() => _CounterState(); +} + +class _CounterState extends State { + int _count = 0; + + @override + Widget build(BuildContext context) { + return ElevatedButton( + onPressed: () => setState(() => _count++), + child: Text('Count: $_count'), + ); + } +} +``` + +#### BuildContext +Widget's location in the tree. Used to: +- Access theme: `Theme.of(context)` +- Access providers: `Provider.of(context)` +- Navigate: `Navigator.of(context)` +- Show dialogs: `showDialog(context: context, ...)` + +#### Keys +Preserve widget state across rebuilds: +```dart +// Use when order might change +ListView( + children: items.map((item) => + ListTile( + key: ValueKey(item.id), // Preserves state + title: Text(item.name), + ), + ).toList(), +) +``` + +#### Widget Lifecycle (StatefulWidget) +``` +createState() → initState() → didChangeDependencies() → build() + ↓ + didUpdateWidget() → build() + ↓ + dispose() +``` + +| Method | When Called | Use For | +|--------|-------------|---------| +| `initState()` | Once, when created | Initialize state, subscriptions | +| `didChangeDependencies()` | After initState, when dependencies change | Access InheritedWidgets | +| `build()` | Every time state changes | Return widget tree | +| `didUpdateWidget()` | When parent passes new config | Compare old/new widget | +| `dispose()` | When removed from tree | Cancel subscriptions, dispose controllers | + +--- + +### 2. Layout System + +#### Box Constraints +Parent tells child: "You must be between minWidth-maxWidth and minHeight-maxHeight." + +```dart +// Tight constraint - exact size +SizedBox(width: 100, height: 100, child: ...) + +// Loose constraint - up to max +ConstrainedBox( + constraints: BoxConstraints(maxWidth: 300), + child: ... +) +``` + +#### Core Layout Widgets + +**Row & Column** +```dart +Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, // Horizontal + crossAxisAlignment: CrossAxisAlignment.center, // Vertical + children: [Widget1(), Widget2(), Widget3()], +) + +Column( + mainAxisAlignment: MainAxisAlignment.start, // Vertical + crossAxisAlignment: CrossAxisAlignment.stretch, // Horizontal (full width) + children: [Widget1(), Widget2()], +) +``` + +**Expanded & Flexible** +```dart +Row( + children: [ + Expanded(flex: 2, child: Container(color: Colors.red)), // 2/3 width + Expanded(flex: 1, child: Container(color: Colors.blue)), // 1/3 width + ], +) +``` + +**Stack** (overlay) +```dart +Stack( + children: [ + Image.asset('background.png'), // Bottom layer + Positioned( // Positioned on top + bottom: 16, + right: 16, + child: FloatingActionButton(...), + ), + ], +) +``` + +**ListView** (scrollable) +```dart +// Static list +ListView(children: [Widget1(), Widget2()]) + +// Dynamic list (efficient) +ListView.builder( + itemCount: items.length, + itemBuilder: (context, index) => ListTile(title: Text(items[index])), +) +``` + +#### Responsive Design +```dart +Widget build(BuildContext context) { + final width = MediaQuery.of(context).size.width; + + if (width < 600) { + return MobileLayout(); + } else { + return DesktopLayout(); + } +} + +// Or use LayoutBuilder for parent constraints +LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < 600) { + return MobileLayout(); + } + return DesktopLayout(); + }, +) +``` + +--- + +### 3. State Management with Provider + +#### ChangeNotifier +```dart +class CartState extends ChangeNotifier { + final List _items = []; + + List get items => List.unmodifiable(_items); + + void addItem(Item item) { + _items.add(item); + notifyListeners(); // Triggers rebuild + } + + void removeItem(Item item) { + _items.remove(item); + notifyListeners(); + } +} +``` + +#### Providing State +```dart +// In main.dart or widget tree +MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => CartState()), + ChangeNotifierProvider(create: (_) => UserState()), + Provider(create: (_) => ApiService()), // No notifications + ], + child: MyApp(), +) +``` + +#### Consuming State +```dart +// Method 1: Provider.of (rebuilds entire widget) +Widget build(BuildContext context) { + final cart = Provider.of(context); + return Text('Items: ${cart.items.length}'); +} + +// Method 2: Consumer (rebuilds only child) +Consumer( + builder: (context, cart, child) { + return Text('Items: ${cart.items.length}'); + }, +) + +// Method 3: context.watch (same as Provider.of) +final cart = context.watch(); + +// Method 4: context.read (no rebuild, for methods) +onPressed: () => context.read().addItem(item) +``` + +#### When to Use What +| Scenario | Solution | +|----------|----------| +| Display data | `context.watch()` or `Consumer` | +| Call methods | `context.read()` | +| Optimize rebuilds | Use `Consumer` around specific widgets | +| Listen without rebuild | Use `listen: false` | + +--- + +### 4. Navigation + +#### Navigator 1.0 (Current) +```dart +// Push new screen +Navigator.push( + context, + MaterialPageRoute(builder: (context) => DetailScreen(id: item.id)), +); + +// Pop current screen +Navigator.pop(context); + +// Pop with result +Navigator.pop(context, selectedValue); + +// Push and remove all previous +Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (context) => HomeScreen()), + (route) => false, +); +``` + +#### go_router (Recommended for scaling) +```dart +// Define routes +final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => HomeScreen(), + routes: [ + GoRoute( + path: 'book/:id', + builder: (context, state) { + final id = state.pathParameters['id']!; + return BookDetailScreen(id: id); + }, + ), + ], + ), + ], + redirect: (context, state) { + final isLoggedIn = context.read().isLoggedIn; + if (!isLoggedIn && state.matchedLocation != '/login') { + return '/login'; + } + return null; + }, +); + +// Navigate +context.go('/book/123'); +context.push('/book/123'); +``` + +--- + +### 5. Supabase Integration + +#### Authentication +```dart +final supabase = Supabase.instance.client; + +// Sign up +await supabase.auth.signUp( + email: email, + password: password, +); + +// Sign in +await supabase.auth.signInWithPassword( + email: email, + password: password, +); + +// Sign out +await supabase.auth.signOut(); + +// Listen to auth changes +supabase.auth.onAuthStateChange.listen((data) { + final event = data.event; + final session = data.session; + + if (event == AuthChangeEvent.signedIn) { + // Handle sign in + } else if (event == AuthChangeEvent.signedOut) { + // Handle sign out + } +}); + +// Get current user +final user = supabase.auth.currentUser; +``` + +#### Database Operations +```dart +// SELECT +final data = await supabase + .from('books') + .select() + .eq('user_id', userId) + .order('created_at', ascending: false); + +// INSERT +await supabase.from('books').insert({ + 'title': title, + 'author': author, + 'user_id': userId, +}); + +// UPDATE +await supabase + .from('books') + .update({'title': newTitle}) + .eq('id', bookId); + +// DELETE +await supabase + .from('books') + .delete() + .eq('id', bookId); + +// Real-time subscription +supabase + .from('books') + .stream(primaryKey: ['id']) + .eq('user_id', userId) + .listen((data) { + // Handle updates + }); +``` + +#### Storage +```dart +// Upload file +await supabase.storage + .from('avatars') + .upload('user_$userId.png', file); + +// Get public URL +final url = supabase.storage + .from('avatars') + .getPublicUrl('user_$userId.png'); + +// Download file +final bytes = await supabase.storage + .from('avatars') + .download('user_$userId.png'); +``` + +--- + +### 6. Platform-Specific Knowledge + +#### iOS Configuration + +**Info.plist** (`ios/Runner/Info.plist`) +```xml + +NSCameraUsageDescription +We need camera access to take photos + + +NSPhotoLibraryUsageDescription +We need photo access to upload images + + +NSLocationWhenInUseUsageDescription +We need location to show nearby items +``` + +**Key iOS Concepts**: +- **Safe Area**: Use `SafeArea` widget for notch/home indicator +- **Cupertino widgets**: iOS-native look (`CupertinoButton`, `CupertinoTextField`) +- **App Icons**: Provide 1024x1024 source, use asset generator +- **Launch Screen**: Configure in `LaunchScreen.storyboard` + +#### Android Configuration + +**AndroidManifest.xml** (`android/app/src/main/AndroidManifest.xml`) +```xml + + + + + + + + +``` + +**build.gradle** (`android/app/build.gradle`) +```groovy +android { + compileSdk 34 + + defaultConfig { + minSdk 21 + targetSdk 34 + versionCode 1 + versionName "1.0.0" + } +} +``` + +**Key Android Concepts**: +- **Material Design**: Use Material widgets for native look +- **Back button**: Handle with `WillPopScope` or `PopScope` +- **Runtime permissions**: Request at runtime for camera, location +- **App signing**: Keystore required for release builds + +--- + +### 7. Testing + +#### Unit Tests +```dart +// test/services/auth_service_test.dart +import 'package:test/test.dart'; + +void main() { + group('AuthService', () { + late AuthService authService; + + setUp(() { + authService = AuthService(); + }); + + test('validates email format', () { + expect(authService.isValidEmail('test@example.com'), isTrue); + expect(authService.isValidEmail('invalid'), isFalse); + }); + }); +} +``` + +#### Widget Tests +```dart +// test/widgets/book_card_test.dart +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('BookCard displays title and author', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: BookCard( + book: Book(title: 'Flutter Guide', author: 'John'), + ), + ), + ); + + expect(find.text('Flutter Guide'), findsOneWidget); + expect(find.text('John'), findsOneWidget); + }); + + testWidgets('BookCard tap calls onTap', (tester) async { + bool tapped = false; + + await tester.pumpWidget( + MaterialApp( + home: BookCard( + book: testBook, + onTap: () => tapped = true, + ), + ), + ); + + await tester.tap(find.byType(BookCard)); + expect(tapped, isTrue); + }); +} +``` + +#### Mocking with Mocktail +```dart +import 'package:mocktail/mocktail.dart'; + +class MockBookService extends Mock implements BookService {} + +void main() { + late MockBookService mockService; + + setUp(() { + mockService = MockBookService(); + }); + + test('loads books from service', () async { + when(() => mockService.getBooks()) + .thenAnswer((_) async => [testBook]); + + final books = await mockService.getBooks(); + expect(books.length, 1); + verify(() => mockService.getBooks()).called(1); + }); +} +``` + +--- + +## iOS Deployment Guide + +### Prerequisites +- Mac with Xcode 15+ installed +- Apple Developer account ($99/year): https://developer.apple.com +- Physical iOS device (required for push notifications, some APIs) + +### Step 1: Apple Developer Portal Setup + +1. **Create App ID**: + - Go to Certificates, Identifiers & Profiles + - Create new Identifier → App IDs + - Bundle ID: `com.yourcompany.myapp` (must match Xcode) + - Enable capabilities: Push Notifications, Sign in with Apple, etc. + +2. **Create Certificates**: + - Development certificate (for testing) + - Distribution certificate (for App Store) + - Download and double-click to install in Keychain + +3. **Create Provisioning Profiles**: + - Development profile (link to devices) + - App Store distribution profile + - Download and double-click to install + +### Step 2: Xcode Configuration + +1. Open `ios/Runner.xcworkspace` in Xcode +2. Select Runner project → Signing & Capabilities +3. Set Team (your Apple Developer account) +4. Set Bundle Identifier (must match App ID) +5. Configure version and build number +6. Add app icons in Assets.xcassets + +### Step 3: Build for Release + +```bash +# Build IPA for App Store +flutter build ipa + +# Output: build/ios/ipa/myapp.ipa +``` + +### Step 4: Upload to App Store Connect + +1. Go to https://appstoreconnect.apple.com +2. Create new app (if first time) +3. Fill in app information: + - Name, subtitle, description + - Keywords, categories + - Screenshots (required sizes for each device) + - App icon, privacy policy URL +4. Upload IPA via Xcode or Transporter app +5. Select build and submit for review + +### Step 5: TestFlight (Beta Testing) + +1. Upload build to App Store Connect +2. Go to TestFlight tab +3. **Internal Testing**: Add team members (up to 100, instant) +4. **External Testing**: + - Create group + - Add testers by email (up to 10,000) + - Requires Beta App Review (usually 24-48 hours) + +### Common iOS Issues + +| Issue | Solution | +|-------|----------| +| Code signing error | Check provisioning profile matches bundle ID | +| Archive fails | Clean build folder (Cmd+Shift+K), rebuild | +| Push notifications not working | Enable in App ID capabilities | +| Rejected for permissions | Add all required Info.plist descriptions | + +--- + +## Android Deployment Guide + +### Prerequisites +- Google Play Console account ($25 one-time): https://play.google.com/console +- Java/OpenJDK for keytool + +### Step 1: Create Signing Key + +```bash +# Generate upload keystore +keytool -genkey -v -keystore ~/upload-keystore.jks \ + -keyalg RSA -keysize 2048 -validity 10000 \ + -alias upload + +# IMPORTANT: Save password securely, you cannot recover it! +``` + +### Step 2: Configure Signing + +Create `android/key.properties` (add to .gitignore!): +```properties +storePassword=your_keystore_password +keyPassword=your_key_password +keyAlias=upload +storeFile=/Users/yourusername/upload-keystore.jks +``` + +Update `android/app/build.gradle`: +```groovy +def keystoreProperties = new Properties() +def keystorePropertiesFile = rootProject.file('key.properties') +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) +} + +android { + signingConfigs { + release { + keyAlias keystoreProperties['keyAlias'] + keyPassword keystoreProperties['keyPassword'] + storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null + storePassword keystoreProperties['storePassword'] + } + } + buildTypes { + release { + signingConfig signingConfigs.release + } + } +} +``` + +### Step 3: Build for Release + +```bash +# Build App Bundle (preferred for Play Store) +flutter build appbundle + +# Output: build/app/outputs/bundle/release/app-release.aab + +# Or build APKs +flutter build apk --split-per-abi +``` + +### Step 4: Google Play Console Setup + +1. Create application +2. Fill store listing: + - App name, short/full description + - Screenshots (phone, tablet, optional: TV, watch) + - Feature graphic (1024x500) + - App icon (512x512) + - Category, tags + - Contact email, privacy policy URL +3. Set content rating (complete questionnaire) +4. Set pricing and distribution + +### Step 5: Release Process + +**Testing Tracks** (recommended order): + +1. **Internal Testing** + - Up to 100 testers + - Instant availability (no review) + - Great for team testing + +2. **Closed Testing** + - Invite-only via email lists + - Requires review (~hours to days) + - Good for beta users + +3. **Open Testing** + - Anyone can join via link + - Requires review + - Public beta + +4. **Production** + - Full public release + - Review required (usually 1-3 days) + +### Step 6: Upload AAB + +1. Go to Release → Production (or testing track) +2. Create new release +3. Upload app-release.aab +4. Add release notes +5. Submit for review + +### Common Android Issues + +| Issue | Solution | +|-------|----------| +| Signing key lost | Cannot update app, must create new listing | +| Version code conflict | Increment versionCode in build.gradle | +| 64-bit requirement | Flutter handles automatically | +| Target API level warning | Update targetSdk in build.gradle | + +--- + +## Development Commands + +```bash +# === Development === +flutter run # Run debug on connected device +flutter run -d # Run on specific device +flutter run --release # Run release build +flutter devices # List connected devices + +# === Building === +flutter build ios # Build iOS (debug) +flutter build ipa # Build iOS archive (release) +flutter build apk # Build Android APK +flutter build appbundle # Build Android App Bundle + +# === Testing === +flutter test # Run all tests +flutter test test/unit/ # Run tests in directory +flutter test --coverage # Generate coverage report +flutter test --update-goldens # Update golden files + +# === Code Quality === +flutter analyze # Static analysis +dart format lib/ # Format all code +dart fix --apply # Apply automated fixes + +# === Dependencies === +flutter pub get # Install dependencies +flutter pub upgrade # Upgrade dependencies +flutter pub outdated # Check for updates + +# === Maintenance === +flutter clean # Clean build artifacts +flutter pub cache repair # Fix corrupted cache +flutter doctor # Check environment + +# === Useful Flags === +flutter run --verbose # Detailed output +flutter build apk --split-per-abi # Separate APKs per architecture +flutter build ipa --export-options-plist=ExportOptions.plist +``` + +--- + +## Coding Standards + +### File Naming +``` +snake_case.dart # All Dart files +auth_service.dart # Classes +book_model.dart # Models +home_screen.dart # Screens/pages +primary_button.dart # Widgets +``` + +### Class Naming +```dart +AuthService # PascalCase for classes +_PrivateHelper # Underscore prefix for private +BookModel # Suffix with type (Model, Service, etc.) +``` + +### Widget Structure +```dart +class BookCard extends StatelessWidget { + // 1. Constructor (use const when possible) + const BookCard({ + super.key, + required this.book, + this.onTap, + }); + + // 2. Final fields + final Book book; + final VoidCallback? onTap; + + // 3. Build method + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTitle(), + const SizedBox(height: 8), + _buildAuthor(), + ], + ), + ), + ), + ); + } + + // 4. Private helper methods + Widget _buildTitle() { + return Text( + book.title, + style: const TextStyle(fontWeight: FontWeight.bold), + ); + } + + Widget _buildAuthor() { + return Text(book.author); + } +} +``` + +### Error Handling +```dart +// Define custom exceptions +class AppException implements Exception { + final String message; + final String? code; + + AppException(this.message, {this.code}); + + @override + String toString() => message; +} + +class AuthException extends AppException { + AuthException(super.message, {super.code}); +} + +// Handle errors consistently +Future signIn(String email, String password) async { + try { + await supabase.auth.signInWithPassword( + email: email, + password: password, + ); + } on AuthException catch (e) { + // Known auth errors + throw AuthException(e.message); + } catch (e) { + // Unexpected errors + throw AppException('Failed to sign in. Please try again.'); + } +} + +// In UI +try { + await authService.signIn(email, password); +} on AuthException catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.message)), + ); +} +``` + +### Const Usage +```dart +// Use const for immutable widgets +const SizedBox(height: 16) +const EdgeInsets.all(8) +const Text('Static text') + +// Use const constructors +const MyWidget({super.key}); + +// Don't use const when values are dynamic +SizedBox(height: spacing) // spacing is variable +Text(userName) // userName is variable +``` + +--- + +## Key Files Reference + +| File | Purpose | +|------|---------| +| `lib/main.dart` | App entry, Supabase init, root widget | +| `lib/controller/auth_service.dart` | Authentication methods | +| `lib/controller/book_service.dart` | Book CRUD operations | +| `lib/model/main_state.dart` | Global app state | +| `lib/model/book.dart` | Book data model | +| `lib/view/menu.dart` | Main navigation scaffold | +| `pubspec.yaml` | Dependencies and metadata | +| `ios/Runner/Info.plist` | iOS permissions and config | +| `android/app/build.gradle` | Android build config | +| `WIP.md` | Full feature roadmap | + +--- + +## Resources + +- [Flutter Docs](https://docs.flutter.dev) +- [Supabase Docs](https://supabase.com/docs) +- [Provider Package](https://pub.dev/packages/provider) +- [go_router Package](https://pub.dev/packages/go_router) +- [Material 3 Design](https://m3.material.io) +- [Apple HIG](https://developer.apple.com/design/human-interface-guidelines) +- [Flutter Cookbook](https://docs.flutter.dev/cookbook) diff --git a/.gitignore b/.gitignore index 98a3069..b404dc3 100644 --- a/.gitignore +++ b/.gitignore @@ -17,12 +17,7 @@ migrate_working_dir/ *.ipr *.iws .idea/ - -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ - +.vscode # Flutter/Dart/Pub related **/ios/Flutter/.last_build_id .dart_tool/ diff --git a/WIP.md b/WIP.md index 81cfd20..ee36322 100644 --- a/WIP.md +++ b/WIP.md @@ -45,7 +45,7 @@ Groups are collections of people (like Telegram/WhatsApp groups): ### Phase 1: Foundation #### Authentication -- [ ] Email/password login +- [X] Email/password login - [ ] Google Sign-In - [ ] Apple Sign-In (iOS) - [ ] Password reset flow diff --git a/assets/config/dev.json b/assets/config/dev.json new file mode 100644 index 0000000..3b331d4 --- /dev/null +++ b/assets/config/dev.json @@ -0,0 +1,16 @@ +{ + "env": "development", + "enable_logging": true, + "supabase": { + "url": "http://127.0.0.1:54321", + "url_android": "http://10.0.2.2:54321", + "anon_key": "sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH" + }, + "storage": { + "url": "http://127.0.0.1:54321/storage/v1/s3", + "url_android": "http://10.0.2.2:54321/storage/v1/s3", + "access_key": "625729a08b95bf1b7ff351a663f3a23c", + "secret_key": "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + "region": "local" + } +} diff --git a/assets/config/prod.json b/assets/config/prod.json new file mode 100644 index 0000000..ac6571a --- /dev/null +++ b/assets/config/prod.json @@ -0,0 +1,14 @@ +{ + "env": "production", + "enable_logging": false, + "supabase": { + "url": "https://hohxbxrqglxfsgeatdvd.supabase.co", + "anon_key": "sb_publishable_y4ZHAHbVLoK26qupHesTZQ_3fAG1_3x" + }, + "storage": { + "url": "https://hohxbxrqglxfsgeatdvd.supabase.co/storage/v1/s3", + "access_key": "", + "secret_key": "", + "region": "" + } +} diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart new file mode 100644 index 0000000..e84d4ad --- /dev/null +++ b/lib/config/app_config.dart @@ -0,0 +1,114 @@ +import 'dart:convert'; +import 'dart:io' show Platform; +import 'package:flutter/services.dart'; + +class SupabaseConfig { + final String url; + final String anonKey; + + const SupabaseConfig({ + required this.url, + required this.anonKey, + }); + + factory SupabaseConfig.fromJson(Map json) { + return SupabaseConfig( + url: json['url'] as String, + anonKey: json['anon_key'] as String, + ); + } +} + +class StorageConfig { + final String url; + final String accessKey; + final String secretKey; + final String region; + + const StorageConfig({ + required this.url, + required this.accessKey, + required this.secretKey, + required this.region, + }); + + factory StorageConfig.fromJson(Map json) { + return StorageConfig( + url: json['url'] as String, + accessKey: json['access_key'] as String, + secretKey: json['secret_key'] as String, + region: json['region'] as String, + ); + } +} + +class AppConfig { + final String env; + final bool enableLogging; + final SupabaseConfig supabase; + final StorageConfig storage; + + const AppConfig({ + required this.env, + required this.enableLogging, + required this.supabase, + required this.storage, + }); + + /// Global instance - set during app initialization + static late AppConfig instance; + + /// Get environment from --dart-define=ENV=dev (defaults to 'dev') + static const String environment = + String.fromEnvironment('ENV', defaultValue: 'dev'); + + /// Whether we're in development mode + bool get isDevelopment => env == 'development'; + + /// Whether we're in production mode + bool get isProduction => env == 'production'; + + /// Get platform-specific URL from config. + /// Uses url_android if on Android and available, otherwise falls back to url. + static String _getUrl(Map json) { + final androidUrl = json['url_android'] as String?; + final defaultUrl = json['url'] as String; + return (Platform.isAndroid && androidUrl != null) ? androidUrl : defaultUrl; + } + + /// Load configuration from JSON asset file based on ENV + static Future load() async { + final jsonString = await rootBundle.loadString( + 'assets/config/$environment.json', + ); + final json = jsonDecode(jsonString) as Map; + + final supabaseJson = json['supabase'] as Map; + final storageJson = json['storage'] as Map; + + final config = AppConfig( + env: json['env'] as String, + enableLogging: json['enable_logging'] as bool? ?? false, + supabase: SupabaseConfig( + url: _getUrl(supabaseJson), + anonKey: supabaseJson['anon_key'] as String, + ), + storage: StorageConfig( + url: _getUrl(storageJson), + accessKey: storageJson['access_key'] as String, + secretKey: storageJson['secret_key'] as String, + region: storageJson['region'] as String, + ), + ); + + instance = config; + return config; + } + + /// Log only in development + void log(String message) { + if (enableLogging) { + print('[$env] $message'); + } + } +} diff --git a/lib/controller/auth_service.dart b/lib/controller/auth_service.dart index 3f7fae7..e8dea77 100644 --- a/lib/controller/auth_service.dart +++ b/lib/controller/auth_service.dart @@ -52,11 +52,41 @@ class AuthService { return user?.email; } - Future deleteAccount() async { + /// Disables account by setting disabled_at timestamp. + /// This preserves all user data including purchases. + Future disableAccount() async { final user = _supabase.auth.currentUser; if (user != null) { - await _supabase.from('users').delete().eq('id', user.id); + await _supabase.from('profiles').update({ + 'disabled_at': DateTime.now().toUtc().toIso8601String(), + }).eq('id', user.id); await _supabase.auth.signOut(); } } + + /// Checks if the current user's account is disabled. + /// Returns the disabled_at timestamp if disabled, null if active. + Future checkAccountDisabled() async { + final user = _supabase.auth.currentUser; + if (user == null) return null; + + final response = await _supabase + .from('profiles') + .select('disabled_at') + .eq('id', user.id) + .single(); + + final disabledAt = response['disabled_at']; + return disabledAt != null ? DateTime.parse(disabledAt) : null; + } + + /// Re-enables account by clearing disabled_at. + Future enableAccount() async { + final user = _supabase.auth.currentUser; + if (user != null) { + await _supabase.from('profiles').update({ + 'disabled_at': null, + }).eq('id', user.id); + } + } } diff --git a/lib/controller/pages/login_page.dart b/lib/controller/pages/login_page.dart index 45c1f43..024ac96 100644 --- a/lib/controller/pages/login_page.dart +++ b/lib/controller/pages/login_page.dart @@ -4,6 +4,7 @@ import 'package:myapp/controller/navigation.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import '../../view/auth/login_scaffold.dart'; +import '../../view/auth/reenable_account_dialog.dart'; import '../auth_service.dart'; class LoginPage extends StatefulWidget { @@ -25,7 +26,24 @@ class _LoginPageState extends State { try { await authService.signInWithEmailPassword(email, password); - if (mounted) _navigationService.navigateToMain(context); + + if (!mounted) return; + final disabledAt = await authService.checkAccountDisabled(); + + if (!mounted) return; + if (disabledAt != null) { + final shouldReenable = await showReenableAccountDialog(context); + + if (!mounted) return; + if (shouldReenable) { + await authService.enableAccount(); + if (mounted) _navigationService.navigateToMain(context); + } else { + await authService.signOut(); + } + } else { + _navigationService.navigateToMain(context); + } } catch (error) { if (mounted) { if (error is AuthException && diff --git a/lib/controller/pages/profile_page.dart b/lib/controller/pages/profile_page.dart index 4cbb1c9..89fc2f4 100644 --- a/lib/controller/pages/profile_page.dart +++ b/lib/controller/pages/profile_page.dart @@ -20,9 +20,33 @@ class _ProfilePageState extends State { if (mounted) _navigationService.navigateToLogin(context); } - void deleteAccount() async { - await Provider.of(context, listen: false).deleteAccount(); - if (mounted) _navigationService.navigateToLogin(context); + void disableAccount() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Disable Account'), + content: const Text( + 'Your account will be disabled and you will be signed out. ' + 'You can re-enable it by logging in again. ' + 'Your data and purchases will be preserved.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Disable'), + ), + ], + ), + ); + + if (confirmed == true && mounted) { + await Provider.of(context, listen: false).disableAccount(); + if (mounted) _navigationService.navigateToLogin(context); + } } @override @@ -32,7 +56,7 @@ class _ProfilePageState extends State { return ProfileScaffold( email: email, onLogout: logout, - onDeleteAccount: deleteAccount, + onDisableAccount: disableAccount, ); } } diff --git a/lib/main.dart b/lib/main.dart index bf27712..cadadf2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:myapp/config/app_config.dart'; import 'package:myapp/controller/auth_service.dart'; import 'package:myapp/controller/pages/login_page.dart'; import 'package:provider/provider.dart'; @@ -10,12 +11,19 @@ import 'view/menu.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + + // Load configuration from JSON (ENV comes from --dart-define) + final config = await AppConfig.load(); + config.log('Starting app with ${config.env} configuration'); + config.log('Supabase URL: ${config.supabase.url}'); + + // Initialize Supabase with config values await Supabase.initialize( - url: 'https://jgywuqgtfzblbaqprvdb.supabase.co', - anonKey: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImpneXd1cWd0ZnpibGJhcXBydmRiIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzYwMTg3NjQsImV4cCI6MjA1MTU5NDc2NH0.A2lzmtfewM5Rm9LpZlWq4u9fjcPHwmjYNk-y5wD_dBo', + url: config.supabase.url, + anonKey: config.supabase.anonKey, ); - runApp(MyApp()); + + runApp(const MyApp()); } class MyApp extends StatelessWidget { @@ -47,7 +55,9 @@ class MyApp extends StatelessWidget { future: _isLoggedIn(), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { - return const CircularProgressIndicator(); + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); } else if (snapshot.hasData && snapshot.data == true) { return Menu(); } else { diff --git a/lib/view/auth/profile_scaffold.dart b/lib/view/auth/profile_scaffold.dart index 4c68e5a..ea41dac 100644 --- a/lib/view/auth/profile_scaffold.dart +++ b/lib/view/auth/profile_scaffold.dart @@ -4,13 +4,13 @@ import 'package:flutter/material.dart'; class ProfileScaffold extends StatelessWidget { final String? email; final VoidCallback onLogout; - final VoidCallback onDeleteAccount; + final VoidCallback onDisableAccount; const ProfileScaffold({ super.key, required this.email, required this.onLogout, - required this.onDeleteAccount, + required this.onDisableAccount, }); @override @@ -27,11 +27,11 @@ class ProfileScaffold extends StatelessWidget { ), const SizedBox(height: 16), ElevatedButton( - onPressed: onDeleteAccount, + onPressed: onDisableAccount, style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, // Red color for delete button + backgroundColor: Colors.orange, ), - child: const Text('Delete Account'), + child: const Text('Disable Account'), ), ], ), diff --git a/lib/view/auth/reenable_account_dialog.dart b/lib/view/auth/reenable_account_dialog.dart new file mode 100644 index 0000000..6e17f97 --- /dev/null +++ b/lib/view/auth/reenable_account_dialog.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +/// Shows a dialog asking if the user wants to re-enable their disabled account. +/// Returns true if user wants to re-enable, false if they want to stay signed out. +Future showReenableAccountDialog(BuildContext context) async { + final result = await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + title: const Text('Account Disabled'), + content: const Text( + 'Your account is currently disabled. Would you like to re-enable it?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('No, Sign Out'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Yes, Re-enable'), + ), + ], + ), + ); + return result ?? false; +} diff --git a/pubspec.lock b/pubspec.lock index d4eda71..93c83b8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -420,10 +420,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -769,26 +769,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" url: "https://pub.dev" source: hosted - version: "1.26.2" + version: "1.26.3" test_api: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.7" test_core: dependency: transitive description: name: test_core - sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" url: "https://pub.dev" source: hosted - version: "0.6.11" + version: "0.6.12" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 92e5de1..34a4b14 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,15 +42,27 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. flutter_lints: ^5.0.0 test: ^1.25.8 +scripts: + # VS Code Launch (cross-platform: Linux, macOS, Windows) + # Prompts for env/mode, ensures emulators, launches VS Code debug + start:android: dart run scripts/start.dart android + start:ios: dart run scripts/start.dart ios + start:both: dart run scripts/start.dart both + + # Build (production releases) + build: derry build:ios && derry build:android + build:ios: flutter build ipa --dart-define=ENV=prod + build:android: flutter build appbundle --dart-define=ENV=prod + build:apk: flutter build apk --dart-define=ENV=prod --split-per-abi + + # Utilities + clean: flutter clean && flutter pub get + test: flutter test + analyze: flutter analyze + # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec @@ -62,10 +74,9 @@ flutter: # the material Icons class. uses-material-design: true - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg + # Assets + assets: + - assets/config/ # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images diff --git a/scripts/start.dart b/scripts/start.dart new file mode 100644 index 0000000..fa54053 --- /dev/null +++ b/scripts/start.dart @@ -0,0 +1,566 @@ +#!/usr/bin/env dart +// Unified launcher for Flutter development. +// +// Usage: +// dart run scripts/start.dart android +// dart run scripts/start.dart ios +// dart run scripts/start.dart both +// +// This script: +// 1. Prompts for environment (dev/prod) and build mode (debug/profile/release) +// 2. Ensures required emulators are running +// 3. Generates VS Code launch.json with correct device IDs +// 4. Triggers VS Code debug session + +import 'dart:convert'; +import 'dart:io'; + +const Duration deviceTimeout = Duration(seconds: 120); +const Duration pollInterval = Duration(seconds: 2); + +void main(List args) async { + final platform = _parsePlatform(args); + + if (platform == null) { + _printUsage(); + exit(1); + } + + print(''); + print('╔══════════════════════════════════════════════════════════════╗'); + print('║ Flutter Development Launcher ║'); + print('╚══════════════════════════════════════════════════════════════╝'); + print(''); + + try { + // Step 1: Prompt for configuration + final env = await _promptChoice( + 'Select environment:', + ['dev', 'prod'], + defaultIndex: 0, + ); + + final mode = await _promptChoice( + 'Select build mode:', + ['debug', 'profile', 'release'], + defaultIndex: 0, + ); + + print(''); + print('Configuration: ENV=$env, mode=$mode'); + print('Platform: $platform'); + print(''); + + // Step 2: Ensure devices are running + String? androidId; + String? iosId; + + if (platform == 'android' || platform == 'both') { + print('Ensuring Android device...'); + androidId = await _ensureAndroidDevice(); + print(' Android ready: $androidId'); + } + + if (platform == 'ios' || platform == 'both') { + if (!Platform.isMacOS) { + print('Warning: iOS is only available on macOS'); + if (platform == 'ios') exit(1); + } else { + print('Ensuring iOS device...'); + iosId = await _ensureIOSDevice(); + print(' iOS ready: $iosId'); + } + } + + // Step 3: Generate VS Code configuration + print(''); + print('Generating VS Code configuration...'); + await _generateVSCodeConfig( + env: env, + mode: mode, + platform: platform, + androidId: androidId, + iosId: iosId, + ); + + // Step 4: Launch VS Code debug session + print('Launching VS Code debug session...'); + await _launchVSCodeDebug(platform); + + print(''); + print('Done! Check VS Code for the debug session.'); + } catch (e) { + print('Error: $e'); + exit(1); + } +} + +String? _parsePlatform(List args) { + if (args.isEmpty) return null; + final platform = args[0].toLowerCase(); + if (!['android', 'ios', 'both'].contains(platform)) return null; + return platform; +} + +void _printUsage() { + print('Usage: dart run scripts/start.dart '); + print(''); + print('Platforms:'); + print(' android Launch on Android emulator'); + print(' ios Launch on iOS simulator (macOS only)'); + print(' both Launch on both platforms'); + print(''); + print('Or use derry:'); + print(' derry start:android'); + print(' derry start:ios'); + print(' derry start:both'); +} + +Future _promptChoice( + String prompt, + List options, { + int defaultIndex = 0, +}) async { + print(prompt); + for (var i = 0; i < options.length; i++) { + final marker = i == defaultIndex ? '*' : ' '; + print(' $marker ${i + 1}. ${options[i]}'); + } + stdout.write('Choice [${defaultIndex + 1}]: '); + + final input = stdin.readLineSync()?.trim() ?? ''; + if (input.isEmpty) return options[defaultIndex]; + + final index = int.tryParse(input); + if (index != null && index >= 1 && index <= options.length) { + return options[index - 1]; + } + + // Try matching by name + final match = options.firstWhere( + (o) => o.toLowerCase() == input.toLowerCase(), + orElse: () => options[defaultIndex], + ); + return match; +} + +// ============================================================================ +// Device Management +// ============================================================================ + +Future>> _getRunningDevices() async { + final result = await Process.run('flutter', ['devices', '--machine'], + runInShell: true); + if (result.exitCode != 0) return []; + + final output = result.stdout as String; + final jsonStart = output.indexOf('['); + if (jsonStart == -1) return []; + + try { + final devices = jsonDecode(output.substring(jsonStart)) as List; + return devices.cast>(); + } catch (_) { + return []; + } +} + +Future _ensureAndroidDevice() async { + // Check if already running + var devices = await _getRunningDevices(); + print(' Checking for running devices: ${devices.map((d) => d['id']).toList()}'); + for (final device in devices) { + final platform = device['targetPlatform'] as String? ?? ''; + if (platform.startsWith('android')) { + print(' Found running Android: ${device['id']}'); + return device['id'] as String; + } + } + + // Get available emulators using flutter + final emulatorsResult = await Process.run( + 'flutter', + ['emulators'], + runInShell: true, + ); + + // Parse emulator IDs from output + final lines = (emulatorsResult.stdout as String).split('\n'); + final emulatorIds = []; + for (final line in lines) { + // Lines with emulator info contain "•" separators + if (line.contains('•') && line.contains('android')) { + final id = line.split('•').first.trim(); + if (id.isNotEmpty) emulatorIds.add(id); + } + } + print(' Available emulators: $emulatorIds'); + + // Create emulator if none exist + String emulatorId; + if (emulatorIds.isEmpty) { + print(' Creating Android emulator...'); + await Process.run( + 'flutter', ['emulators', '--create', '--name', 'flutter_emulator'], + runInShell: true); + emulatorId = 'flutter_emulator'; + } else { + emulatorId = emulatorIds.first; + } + + // Launch emulator using flutter + print(' Launching $emulatorId...'); + if (Platform.isWindows) { + // On Windows, use START to launch truly independently + await Process.run( + 'cmd', + ['/c', 'start', '/b', 'flutter', 'emulators', '--launch', emulatorId], + ); + } else { + // On macOS/Linux, detached mode works properly + await Process.start( + 'flutter', + ['emulators', '--launch', emulatorId], + mode: ProcessStartMode.detached, + runInShell: true, + ); + } + + // Wait for device to appear (using adb - faster, no CMD window flash) + print(' Waiting for emulator to connect...'); + final deviceId = await _waitForAndroidDevice(); + + // Wait for device to fully boot + print(' Waiting for emulator to fully boot...'); + await _waitForAndroidBoot(deviceId); + + return deviceId; +} + +/// Get full path to adb executable +String? _getAdbPath() { + if (Platform.isWindows) { + final localAppData = Platform.environment['LOCALAPPDATA'] ?? ''; + final path = '$localAppData\\Android\\Sdk\\platform-tools\\adb.exe'; + if (File(path).existsSync()) return path; + } else if (Platform.isMacOS) { + final home = Platform.environment['HOME'] ?? ''; + final path = '$home/Library/Android/sdk/platform-tools/adb'; + if (File(path).existsSync()) return path; + } + return null; +} + +/// Run adb command silently (no CMD window on Windows) +Future _runAdb(List args) async { + final adbPath = _getAdbPath(); + if (adbPath != null) { + // Use full path - no shell needed, no CMD window + final result = await Process.run(adbPath, args); + return result.stdout as String; + } else { + // Fallback to PATH lookup (may show CMD window on Windows) + final result = await Process.run('adb', args, runInShell: true); + return result.stdout as String; + } +} + +/// Wait for an Android device to appear using adb +Future _waitForAndroidDevice() async { + final startTime = DateTime.now(); + + while (DateTime.now().difference(startTime) < deviceTimeout) { + final output = await _runAdb( ['devices']); + final lines = output.split('\n'); + for (final line in lines) { + // Format: "emulator-5554 device" or "emulator-5554 offline" + if (line.contains('emulator') && line.contains('device')) { + final deviceId = line.split('\t').first.trim(); + if (deviceId.isNotEmpty) { + print(''); + return deviceId; + } + } + } + await Future.delayed(pollInterval); + stdout.write('.'); + } + + throw Exception('Timeout waiting for Android device'); +} + +/// Wait for Android device to fully boot (sys.boot_completed = 1) +Future _waitForAndroidBoot(String deviceId) async { + final startTime = DateTime.now(); + final bootTimeout = Duration(seconds: 90); + + while (DateTime.now().difference(startTime) < bootTimeout) { + final output = await _runAdb( + ['-s', deviceId, 'shell', 'getprop', 'sys.boot_completed'], + ); + + if (output.trim() == '1') { + print(''); + print(' Emulator fully booted!'); + // Give it a moment to settle + await Future.delayed(Duration(seconds: 2)); + return; + } + + await Future.delayed(pollInterval); + stdout.write('.'); + } + + throw Exception('Timeout waiting for Android device to boot'); +} + +Future _ensureIOSDevice() async { + // Check if already running + var devices = await _getRunningDevices(); + for (final device in devices) { + final platform = device['targetPlatform'] as String? ?? ''; + final isEmulator = device['emulator'] as bool? ?? false; + if (platform == 'ios' && isEmulator) { + return device['id'] as String; + } + } + + // Get available simulators + final simResult = + await Process.run('xcrun', ['simctl', 'list', 'devices', '--json']); + final simJson = + jsonDecode(simResult.stdout as String) as Map; + final simDevices = simJson['devices'] as Map; + + List> simulators = []; + for (final runtime in simDevices.entries) { + if (!runtime.key.contains('iOS')) continue; + for (final device in runtime.value as List) { + final deviceMap = device as Map; + if (deviceMap['isAvailable'] == true) { + final name = deviceMap['name'] as String; + if (name.toLowerCase().contains('iphone')) { + simulators.add({ + 'name': name, + 'udid': deviceMap['udid'] as String, + }); + } + } + } + } + + String udid; + String name; + + if (simulators.isEmpty) { + // Create new simulator + print(' Creating iOS simulator...'); + final runtimeResult = + await Process.run('xcrun', ['simctl', 'list', 'runtimes', '--json']); + final runtimeJson = + jsonDecode(runtimeResult.stdout as String) as Map; + final runtimes = + (runtimeJson['runtimes'] as List).cast>(); + + final iosRuntime = runtimes.lastWhere( + (r) => r['isAvailable'] == true && (r['name'] as String).contains('iOS'), + orElse: () => throw Exception('No iOS runtime available'), + ); + + final createResult = await Process.run('xcrun', [ + 'simctl', + 'create', + 'Flutter iPhone', + 'iPhone 16 Pro', + iosRuntime['identifier'] as String, + ]); + + if (createResult.exitCode != 0) { + throw Exception('Failed to create simulator: ${createResult.stderr}'); + } + + udid = (createResult.stdout as String).trim(); + name = 'Flutter iPhone'; + } else { + udid = simulators.first['udid']!; + name = simulators.first['name']!; + } + + // Boot simulator + print(' Launching $name...'); + await Process.run('xcrun', ['simctl', 'boot', udid]); + await Process.run('open', ['-a', 'Simulator']); + + // Wait for device + return await _waitForDevice( + 'iOS', + () async { + final devices = await _getRunningDevices(); + for (final device in devices) { + final platform = device['targetPlatform'] as String? ?? ''; + final isEmulator = device['emulator'] as bool? ?? false; + if (platform == 'ios' && isEmulator) { + return device['id'] as String?; + } + } + return null; + }, + ); +} + +Future _waitForDevice( + String platform, + Future Function() getDeviceId, +) async { + final startTime = DateTime.now(); + print(' Waiting for $platform device (timeout: ${deviceTimeout.inSeconds}s)...'); + + var attempt = 0; + while (DateTime.now().difference(startTime) < deviceTimeout) { + final id = await getDeviceId(); + if (id != null) { + print('\n Device found: $id'); + return id; + } + await Future.delayed(pollInterval); + attempt++; + if (attempt % 5 == 0) { + print(' Still waiting... (${DateTime.now().difference(startTime).inSeconds}s)'); + } else { + stdout.write('.'); + } + } + + throw Exception('Timeout waiting for $platform device'); +} + +// ============================================================================ +// VS Code Configuration +// ============================================================================ + +Future _generateVSCodeConfig({ + required String env, + required String mode, + required String platform, + String? androidId, + String? iosId, +}) async { + final vscodeDir = Directory('.vscode'); + if (!vscodeDir.existsSync()) { + vscodeDir.createSync(); + } + + // Build configurations based on requested platform only + final configurations = >[]; + List>? compounds; + + if (platform == 'android') { + // Only Android config + configurations.add({ + 'name': 'Flutter (Android)', + 'type': 'dart', + 'request': 'launch', + 'deviceId': androidId!, + 'args': ['--dart-define=ENV=$env'], + 'flutterMode': mode, + }); + } else if (platform == 'ios') { + // Only iOS config + configurations.add({ + 'name': 'Flutter (iOS)', + 'type': 'dart', + 'request': 'launch', + 'deviceId': iosId ?? 'iPhone', + 'args': ['--dart-define=ENV=$env'], + 'flutterMode': mode, + }); + } else if (platform == 'both') { + // Both platforms - need hidden configs for compound + configurations.add({ + 'name': '_Android', + 'type': 'dart', + 'request': 'launch', + 'presentation': {'hidden': true}, + 'deviceId': androidId!, + 'args': ['--dart-define=ENV=$env'], + 'flutterMode': mode, + }); + configurations.add({ + 'name': '_iOS', + 'type': 'dart', + 'request': 'launch', + 'presentation': {'hidden': true}, + 'deviceId': iosId ?? 'iPhone', + 'args': ['--dart-define=ENV=$env'], + 'flutterMode': mode, + }); + compounds = [ + { + 'name': 'Flutter (Both Platforms)', + 'configurations': ['_Android', '_iOS'], + 'stopAll': true, + }, + ]; + } + + final launchJson = { + 'version': '0.2.0', + 'configurations': configurations, + }; + if (compounds != null) { + launchJson['compounds'] = compounds; + } + + final encoder = JsonEncoder.withIndent(' '); + File('.vscode/launch.json') + .writeAsStringSync('${encoder.convert(launchJson)}\n'); + + final configName = platform == 'both' + ? 'Flutter (Both Platforms)' + : platform == 'ios' + ? 'Flutter (iOS)' + : 'Flutter (Android)'; + print(' Generated .vscode/launch.json'); + print(' Config: $configName (ENV=$env, mode=$mode)'); +} + +// ============================================================================ +// VS Code Launch +// ============================================================================ + +Future _launchVSCodeDebug(String platform) async { + if (Platform.isMacOS) { + // Use AppleScript to trigger F5 in VS Code + await Process.run('osascript', [ + '-e', 'tell application "Visual Studio Code" to activate', + '-e', 'delay 0.5', + '-e', 'tell application "System Events" to key code 96', // F5 + ]); + } else if (Platform.isLinux) { + // Use xdotool on Linux + await Process.run('bash', [ + '-c', + ''' + wmctrl -a "Visual Studio Code" 2>/dev/null || code . + sleep 0.5 + xdotool key F5 + ''' + ]); + } else if (Platform.isWindows) { + // Use VBScript on Windows (native, no escaping issues) + final vbsScript = ''' +Set WshShell = CreateObject("WScript.Shell") +WshShell.AppActivate "Visual Studio Code" +WScript.Sleep 500 +WshShell.SendKeys "{F5}" +'''; + final tempDir = Platform.environment['TEMP'] ?? r'C:\Windows\Temp'; + final vbsFile = File('$tempDir\\flutter_launch_vscode.vbs'); + vbsFile.writeAsStringSync(vbsScript); + await Process.run('wscript', [vbsFile.path]); + vbsFile.deleteSync(); + } else { + print(' Note: Auto-launch not supported on this platform.'); + print(' Please press F5 in VS Code to start debugging.'); + } +} diff --git a/setup.md b/setup.md new file mode 100644 index 0000000..2091dfc --- /dev/null +++ b/setup.md @@ -0,0 +1,329 @@ +# MyFlutterPersonalApp - Development Setup Guide + +This guide covers local development setup for **macOS**, **Linux**, and **Windows**. + +--- + +## Table of Contents +1. [Prerequisites](#prerequisites) +2. [Supabase CLI Installation](#supabase-cli-installation) +3. [Project Setup](#project-setup) +4. [Running the App](#running-the-app) +5. [Supabase Commands Reference](#supabase-commands-reference) +6. [External Supabase Docker Setup](#external-supabase-docker-setup) +7. [Troubleshooting](#troubleshooting) + +--- + +## Prerequisites + +Before starting, ensure you have: +- **Flutter SDK** (3.6+) installed and in PATH +- **Docker Desktop** running (required for local Supabase) +- **Git** installed + +Verify Flutter installation: +```bash +flutter doctor +``` + +--- + +## Supabase CLI Installation + +### macOS +```bash +brew install supabase/tap/supabase +``` + +### Linux (Homebrew) +```bash +# Install Homebrew first if not present: https://brew.sh +brew install supabase/tap/supabase +``` + +### Linux (Alternative - Direct Download) +```bash +# Download latest release +curl -L https://github.com/supabase/cli/releases/latest/download/supabase_linux_amd64.tar.gz | tar -xz + +# Move to PATH +sudo mv supabase /usr/local/bin/ +``` + +### Windows (Scoop) +```powershell +scoop bucket add supabase https://github.com/supabase/scoop-bucket.git +scoop install supabase +``` + +### Windows (Chocolatey) +```powershell +choco install supabase +``` + +Verify installation: +```bash +supabase --version +``` + +--- + +## Project Setup + +### 1. Clone and Install Dependencies + +```bash +git clone +cd MyFlutterPersonalApp +flutter pub get +``` + +### 2. Link to Remote Supabase Project (if applicable) + +```bash +supabase link --project-ref your-project-ref +``` + +### 3. Pull Existing Schema + +```bash +supabase db pull +``` + +### 4. Start Local Supabase + +```bash +supabase start +``` + +This starts the local Supabase stack. Access Studio at: **http://localhost:54323** + +--- + +## Running the App + +### Option A: Using Derry (Recommended) + +Derry is a script runner for Dart. Install it globally: + +#### macOS / Linux (zsh) +```bash +dart pub global activate derry +echo 'export PATH="$PATH":"$HOME/.pub-cache/bin"' >> ~/.zshrc +source ~/.zshrc +``` + +#### macOS / Linux (bash) +```bash +dart pub global activate derry +echo 'export PATH="$PATH":"$HOME/.pub-cache/bin"' >> ~/.bashrc +source ~/.bashrc +``` + +#### Windows (PowerShell) +```powershell +dart pub global activate derry + +# Add to PATH permanently (run as Administrator or add manually) +$env:Path += ";$env:USERPROFILE\AppData\Local\Pub\Cache\bin" + +# To make permanent, add to your PowerShell profile: +# notepad $PROFILE +# Add: $env:Path += ";$env:USERPROFILE\AppData\Local\Pub\Cache\bin" +``` + +#### Windows (CMD) +```cmd +dart pub global activate derry + +:: Add to PATH via System Properties > Environment Variables +:: Add: %USERPROFILE%\AppData\Local\Pub\Cache\bin +``` + +#### Available Derry Commands + +```bash +# Development (VS Code debugging with hot reload) +derry start:android # Launch on Android emulator +derry start:ios # Launch on iOS simulator (macOS) +derry start:both # Launch on both platforms (macOS) + +# Production builds +derry build # Build for iOS and Android +derry build:ios # Build IPA for App Store +derry build:android # Build AAB for Play Store +derry build:apk # Build APKs for direct install + +# Utilities +derry clean # Clean and reinstall dependencies +derry test # Run tests +derry analyze # Run static analysis +``` + +### VS Code Launch with Derry + +The `start:*` commands provide a fully automated launch experience: + +**What happens:** +1. **Terminal prompts** for environment (`dev`/`prod`) and build mode (`debug`/`profile`/`release`) +2. **Auto-creates emulator/simulator** if none exists +3. **Auto-starts** the emulator/simulator if not running +4. **Generates VS Code config** with correct device IDs +5. **Triggers F5** in VS Code to start debugging with full hot reload + +This works cross-platform: **macOS**, **Linux**, and **Windows**. + +### Option B: Using Flutter Directly + +```bash +# Development (local Supabase) +flutter run --dart-define=ENV=dev + +# Production +flutter run --dart-define=ENV=prod +``` + +--- + +## Supabase Commands Reference + +| Command | Description | +|---------|-------------| +| `supabase start` | Start local Supabase stack | +| `supabase stop` | Stop local Supabase stack | +| `supabase db pull` | Pull schema from remote to create migrations | +| `supabase db push` | Push migrations to remote | +| `supabase db reset` | Reset local DB (applies all migrations) | +| `supabase db diff -f ` | Generate migration from local changes | +| `supabase migration up` | Apply migrations without data loss | +| `supabase status` | Show local Supabase status and URLs | + +### Local Supabase URLs (Default Ports) + +| Service | URL | +|---------|-----| +| API | http://localhost:54321 | +| Studio | http://localhost:54323 | +| Inbucket (Email) | http://localhost:54324 | +| Database | postgresql://postgres:postgres@localhost:54322/postgres | + +--- + +## Connecting to Remote Supabase Server + +If you want to run Supabase on a separate machine (e.g., a local network server) and connect your Flutter app to it, follow these steps. + +### Server Setup (On Remote Machine) + +Install Supabase CLI on the server and set it up the same way as locally: + +```bash +# Install Supabase CLI (see installation section above) +brew install supabase/tap/supabase + +# Clone the project and init +git clone +cd MyFlutterPersonalApp +supabase link --project-ref your-project-ref +supabase db pull +supabase start +``` + +After `supabase start`, note the credentials shown (API URL, anon key, etc.). The server will be accessible at `http://:54321`. + +### Client Setup (Each Developer) + +Each developer needs to update their local `assets/config/dev.json` to point to the server: + +```json +{ + "env": "development", + "enable_logging": true, + "supabase": { + "url": "http://:54321", + "url_android": "http://:54321", + "anon_key": "" + }, + "storage": { + "url": "http://:54321/storage/v1/s3", + "url_android": "http://:54321/storage/v1/s3", + "access_key": "", + "secret_key": "", + "region": "local" + } +} +``` + +Replace `` with the server's IP address (e.g., `192.168.1.100`). + +### Pushing Schema Changes + +When the Supabase instance runs on a different machine than where you develop: + +1. **Schema changes via Studio**: Make changes at `http://:54323`, then generate migration on server: + ```bash + # On server + supabase db diff -f my_change + git add supabase/migrations/ + git commit -m "Add migration" + git push + ``` + +2. **Pull migrations to local**: Other developers pull the new migration files via git. + +3. **Push to production**: Run `supabase db push` from any machine that has the project linked to the remote Supabase project. + +**Note:** The machine pushing to production needs to have run `supabase link --project-ref ` to be authenticated with the remote project + +--- + +## Troubleshooting + +### Docker Not Running +``` +Error: Cannot connect to Docker daemon +``` +**Solution:** Start Docker Desktop application. + +### Port Already in Use +``` +Error: port 54321 is already in use +``` +**Solution:** Stop other Supabase instances or change ports in `supabase/config.toml`. + +### Flutter Command Not Found (Windows) +Ensure Flutter is in your PATH: +```powershell +# Check if Flutter is accessible +where.exe flutter +``` + +### Derry Command Not Found +Ensure pub cache bin is in PATH (see [Running the App](#running-the-app) section). + +### Android Emulator Can't Connect to Local Supabase +- Use `10.0.2.2` instead of `localhost` in config +- The app already handles this via `url_android` in config files + +### iOS Emulator Connection Issues +- Ensure Supabase is running +- Check that `http://127.0.0.1:54321` is accessible from Terminal + +--- + +## Quick Start Summary + +```bash +# 1. Install dependencies +flutter pub get + +# 2. Start Supabase +supabase start + +# 3. Run the app (with VS Code debugging) +derry start:both +# or without VS Code +flutter run --dart-define=ENV=dev +``` + diff --git a/supabase/.gitignore b/supabase/.gitignore new file mode 100644 index 0000000..ad9264f --- /dev/null +++ b/supabase/.gitignore @@ -0,0 +1,8 @@ +# Supabase +.branches +.temp + +# dotenvx +.env.keys +.env.local +.env.*.local diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 0000000..fff83d2 --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,384 @@ +# For detailed configuration reference documentation, visit: +# https://supabase.com/docs/guides/local-development/cli/config +# A string used to distinguish different Supabase projects on the same host. Defaults to the +# working directory name when running `supabase init`. +project_id = "MyFlutterPersonalApp" + +[api] +enabled = true +# Port to use for the API URL. +port = 54321 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API +# endpoints. `public` and `graphql_public` schemas are included by default. +schemas = ["public", "graphql_public"] +# Extra schemas to add to the search_path of every request. +extra_search_path = ["public", "extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size +# for accidental or malicious requests. +max_rows = 1000 + +[api.tls] +# Enable HTTPS endpoints locally using a self-signed certificate. +enabled = false +# Paths to self-signed certificate pair. +# cert_path = "../certs/my-cert.pem" +# key_path = "../certs/my-key.pem" + +[db] +# Port to use for the local database URL. +port = 54322 +# Port used by db diff command to initialize the shadow database. +shadow_port = 54320 +# Maximum amount of time to wait for health check when starting the local database. +health_timeout = "2m" +# The database major version to use. This has to be the same as your remote database's. Run `SHOW +# server_version;` on the remote database to check. +major_version = 17 + +[db.pooler] +enabled = false +# Port to use for the local connection pooler. +port = 54329 +# Specifies when a server connection can be reused by other clients. +# Configure one of the supported pooler modes: `transaction`, `session`. +pool_mode = "transaction" +# How many server connections to allow per user/database pair. +default_pool_size = 20 +# Maximum number of client connections allowed. +max_client_conn = 100 + +# [db.vault] +# secret_key = "env(SECRET_VALUE)" + +[db.migrations] +# If disabled, migrations will be skipped during a db push or reset. +enabled = true +# Specifies an ordered list of schema files that describe your database. +# Supports glob patterns relative to supabase directory: "./schemas/*.sql" +schema_paths = [] + +[db.seed] +# If enabled, seeds the database after migrations during a db reset. +enabled = true +# Specifies an ordered list of seed files to load during db reset. +# Supports glob patterns relative to supabase directory: "./seeds/*.sql" +sql_paths = ["./seed.sql"] + +[db.network_restrictions] +# Enable management of network restrictions. +enabled = false +# List of IPv4 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv4 connections. Set empty array to block all IPs. +allowed_cidrs = ["0.0.0.0/0"] +# List of IPv6 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv6 connections. Set empty array to block all IPs. +allowed_cidrs_v6 = ["::/0"] + +[realtime] +enabled = true +# Bind realtime via either IPv4 or IPv6. (default: IPv4) +# ip_version = "IPv6" +# The maximum length in bytes of HTTP request headers. (default: 4096) +# max_header_length = 4096 + +[studio] +enabled = true +# Port to use for Supabase Studio. +port = 54323 +# External URL of the API server that frontend connects to. +api_url = "http://127.0.0.1" +# OpenAI API Key to use for Supabase AI in the Supabase Studio. +openai_api_key = "env(OPENAI_API_KEY)" + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they +# are monitored, and you can view the emails that would have been sent from the web interface. +[inbucket] +enabled = true +# Port to use for the email testing server web interface. +port = 54324 +# Uncomment to expose additional ports for testing user applications that send emails. +# smtp_port = 54325 +# pop3_port = 54326 +# admin_email = "admin@email.com" +# sender_name = "Admin" + +[storage] +enabled = true +# The maximum file size allowed (e.g. "5MB", "500KB"). +file_size_limit = "50MiB" + +# Uncomment to configure local storage buckets +# [storage.buckets.images] +# public = false +# file_size_limit = "50MiB" +# allowed_mime_types = ["image/png", "image/jpeg"] +# objects_path = "./images" + +# Allow connections via S3 compatible clients +[storage.s3_protocol] +enabled = true + +# Image transformation API is available to Supabase Pro plan. +# [storage.image_transformation] +# enabled = true + +# Store analytical data in S3 for running ETL jobs over Iceberg Catalog +# This feature is only available on the hosted platform. +[storage.analytics] +enabled = false +max_namespaces = 5 +max_tables = 10 +max_catalogs = 2 + +# Analytics Buckets is available to Supabase Pro plan. +# [storage.analytics.buckets.my-warehouse] + +# Store vector embeddings in S3 for large and durable datasets +# This feature is only available on the hosted platform. +[storage.vector] +enabled = false +max_buckets = 10 +max_indexes = 5 + +# Vector Buckets is available to Supabase Pro plan. +# [storage.vector.buckets.documents-openai] + +[auth] +enabled = true +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used +# in emails. +site_url = "http://127.0.0.1:3000" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["https://127.0.0.1:3000"] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). +jwt_expiry = 3600 +# JWT issuer URL. If not set, defaults to the local API URL (http://127.0.0.1:/auth/v1). +# jwt_issuer = "" +# Path to JWT signing key. DO NOT commit your signing keys file to git. +# signing_keys_path = "./signing_keys.json" +# If disabled, the refresh token will never expire. +enable_refresh_token_rotation = true +# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. +# Requires enable_refresh_token_rotation = true. +refresh_token_reuse_interval = 10 +# Allow/disallow new user signups to your project. +enable_signup = true +# Allow/disallow anonymous sign-ins to your project. +enable_anonymous_sign_ins = false +# Allow/disallow testing manual linking of accounts +enable_manual_linking = false +# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. +minimum_password_length = 6 +# Passwords that do not meet the following requirements will be rejected as weak. Supported values +# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` +password_requirements = "" + +[auth.rate_limit] +# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled. +email_sent = 2 +# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled. +sms_sent = 30 +# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true. +anonymous_users = 30 +# Number of sessions that can be refreshed in a 5 minute interval per IP address. +token_refresh = 150 +# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users). +sign_in_sign_ups = 30 +# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address. +token_verifications = 30 +# Number of Web3 logins that can be made in a 5 minute interval per IP address. +web3 = 30 + +# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`. +# [auth.captcha] +# enabled = true +# provider = "hcaptcha" +# secret = "" + +[auth.email] +# Allow/disallow new user signups via email to your project. +enable_signup = true +# If enabled, a user will be required to confirm any email change on both the old, and new email +# addresses. If disabled, only the new email is required to confirm. +double_confirm_changes = true +# If enabled, users need to confirm their email address before signing in. +enable_confirmations = false +# If enabled, users will need to reauthenticate or have logged in recently to change their password. +secure_password_change = false +# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. +max_frequency = "1s" +# Number of characters used in the email OTP. +otp_length = 6 +# Number of seconds before the email OTP expires (defaults to 1 hour). +otp_expiry = 3600 + +# Use a production-ready SMTP server +# [auth.email.smtp] +# enabled = true +# host = "smtp.sendgrid.net" +# port = 587 +# user = "apikey" +# pass = "env(SENDGRID_API_KEY)" +# admin_email = "admin@email.com" +# sender_name = "Admin" + +# Uncomment to customize email template +# [auth.email.template.invite] +# subject = "You have been invited" +# content_path = "./supabase/templates/invite.html" + +# Uncomment to customize notification email template +# [auth.email.notification.password_changed] +# enabled = true +# subject = "Your password has been changed" +# content_path = "./templates/password_changed_notification.html" + +[auth.sms] +# Allow/disallow new user signups via SMS to your project. +enable_signup = false +# If enabled, users need to confirm their phone number before signing in. +enable_confirmations = false +# Template for sending OTP to users +template = "Your code is {{ .Code }}" +# Controls the minimum amount of time that must pass before sending another sms otp. +max_frequency = "5s" + +# Use pre-defined map of phone number to OTP for testing. +# [auth.sms.test_otp] +# 4152127777 = "123456" + +# Configure logged in session timeouts. +# [auth.sessions] +# Force log out after the specified duration. +# timebox = "24h" +# Force log out if the user has been inactive longer than the specified duration. +# inactivity_timeout = "8h" + +# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object. +# [auth.hook.before_user_created] +# enabled = true +# uri = "pg-functions://postgres/auth/before-user-created-hook" + +# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. +# [auth.hook.custom_access_token] +# enabled = true +# uri = "pg-functions:////" + +# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. +[auth.sms.twilio] +enabled = false +account_sid = "" +message_service_sid = "" +# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: +auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" + +# Multi-factor-authentication is available to Supabase Pro plan. +[auth.mfa] +# Control how many MFA factors can be enrolled at once per user. +max_enrolled_factors = 10 + +# Control MFA via App Authenticator (TOTP) +[auth.mfa.totp] +enroll_enabled = false +verify_enabled = false + +# Configure MFA via Phone Messaging +[auth.mfa.phone] +enroll_enabled = false +verify_enabled = false +otp_length = 6 +template = "Your code is {{ .Code }}" +max_frequency = "5s" + +# Configure MFA via WebAuthn +# [auth.mfa.web_authn] +# enroll_enabled = true +# verify_enabled = true + +# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, +# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, +# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`. +[auth.external.apple] +enabled = false +client_id = "" +# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: +secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" +# Overrides the default auth redirectUrl. +redirect_uri = "" +# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, +# or any other third-party OIDC providers. +url = "" +# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. +skip_nonce_check = false +# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address. +email_optional = false + +# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard. +# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting. +[auth.web3.solana] +enabled = false + +# Use Firebase Auth as a third-party provider alongside Supabase Auth. +[auth.third_party.firebase] +enabled = false +# project_id = "my-firebase-project" + +# Use Auth0 as a third-party provider alongside Supabase Auth. +[auth.third_party.auth0] +enabled = false +# tenant = "my-auth0-tenant" +# tenant_region = "us" + +# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. +[auth.third_party.aws_cognito] +enabled = false +# user_pool_id = "my-user-pool-id" +# user_pool_region = "us-east-1" + +# Use Clerk as a third-party provider alongside Supabase Auth. +[auth.third_party.clerk] +enabled = false +# Obtain from https://clerk.com/setup/supabase +# domain = "example.clerk.accounts.dev" + +# OAuth server configuration +[auth.oauth_server] +# Enable OAuth server functionality +enabled = false +# Path for OAuth consent flow UI +authorization_url_path = "/oauth/consent" +# Allow dynamic client registration +allow_dynamic_registration = false + +[edge_runtime] +enabled = true +# Supported request policies: `oneshot`, `per_worker`. +# `per_worker` (default) — enables hot reload during local development. +# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks). +policy = "per_worker" +# Port to attach the Chrome inspector for debugging edge functions. +inspector_port = 8083 +# The Deno major version to use. +deno_version = 2 + +# [edge_runtime.secrets] +# secret_key = "env(SECRET_VALUE)" + +[analytics] +enabled = true +port = 54327 +# Configure one of the supported backends: `postgres`, `bigquery`. +backend = "postgres" + +# Experimental features may be deprecated any time +[experimental] +# Configures Postgres storage engine to use OrioleDB (S3) +orioledb_version = "" +# Configures S3 bucket URL, eg. .s3-.amazonaws.com +s3_host = "env(S3_HOST)" +# Configures S3 bucket region, eg. us-east-1 +s3_region = "env(S3_REGION)" +# Configures AWS_ACCESS_KEY_ID for S3 bucket +s3_access_key = "env(S3_ACCESS_KEY)" +# Configures AWS_SECRET_ACCESS_KEY for S3 bucket +s3_secret_key = "env(S3_SECRET_KEY)" diff --git a/supabase/migrations/20260122223230_remote_schema.sql b/supabase/migrations/20260122223230_remote_schema.sql new file mode 100644 index 0000000..96aec6e --- /dev/null +++ b/supabase/migrations/20260122223230_remote_schema.sql @@ -0,0 +1,295 @@ + + + +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + + +COMMENT ON SCHEMA "public" IS 'standard public schema'; + + + +CREATE EXTENSION IF NOT EXISTS "pg_graphql" WITH SCHEMA "graphql"; + + + + + + +CREATE EXTENSION IF NOT EXISTS "pg_stat_statements" WITH SCHEMA "extensions"; + + + + + + +CREATE EXTENSION IF NOT EXISTS "pgcrypto" WITH SCHEMA "extensions"; + + + + + + +CREATE EXTENSION IF NOT EXISTS "supabase_vault" WITH SCHEMA "vault"; + + + + + + +CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA "extensions"; + + + + + + + + +ALTER PUBLICATION "supabase_realtime" OWNER TO "postgres"; + + +GRANT USAGE ON SCHEMA "public" TO "postgres"; +GRANT USAGE ON SCHEMA "public" TO "anon"; +GRANT USAGE ON SCHEMA "public" TO "authenticated"; +GRANT USAGE ON SCHEMA "public" TO "service_role"; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "postgres"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "anon"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "authenticated"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "service_role"; + + + + + + +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON FUNCTIONS TO "postgres"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON FUNCTIONS TO "anon"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON FUNCTIONS TO "authenticated"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON FUNCTIONS TO "service_role"; + + + + + + +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "postgres"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "anon"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "authenticated"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "service_role"; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +drop extension if exists "pg_net"; + + diff --git a/supabase/migrations/20260123020000_create_profiles_table.sql b/supabase/migrations/20260123020000_create_profiles_table.sql new file mode 100644 index 0000000..0827ff4 --- /dev/null +++ b/supabase/migrations/20260123020000_create_profiles_table.sql @@ -0,0 +1,104 @@ +-- ===================================================== +-- Create profiles table with auto-creation trigger +-- ===================================================== + +-- Create profiles table +CREATE TABLE public.profiles ( + -- Primary key references auth.users + id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + + -- Timestamps + created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, + + -- Identity + display_name TEXT, + avatar_url TEXT, + + -- Subscription + is_premium BOOLEAN DEFAULT FALSE NOT NULL, + premium_until TIMESTAMPTZ, + + -- App Settings + theme TEXT DEFAULT 'system' CHECK (theme IN ('light', 'dark', 'system')), + enabled_spaces TEXT[] DEFAULT ARRAY['books']::TEXT[], + + -- Catch-all for future settings + preferences JSONB DEFAULT '{}'::JSONB +); + +-- Add comment for documentation +COMMENT ON TABLE public.profiles IS 'User profiles with app settings and preferences'; +COMMENT ON COLUMN public.profiles.enabled_spaces IS 'Array of space identifiers the user has enabled'; +COMMENT ON COLUMN public.profiles.preferences IS 'JSONB for misc settings (notifications, language, currency, etc.)'; + +-- ===================================================== +-- Auto-update updated_at timestamp +-- ===================================================== + +CREATE OR REPLACE FUNCTION public.handle_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER on_profiles_updated + BEFORE UPDATE ON public.profiles + FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at(); + +-- ===================================================== +-- Auto-create profile on user signup +-- ===================================================== + +CREATE OR REPLACE FUNCTION public.handle_new_user() +RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO public.profiles (id, display_name, avatar_url) + VALUES ( + NEW.id, + COALESCE(NEW.raw_user_meta_data->>'full_name', NEW.raw_user_meta_data->>'name'), + NEW.raw_user_meta_data->>'avatar_url' + ); + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +CREATE TRIGGER on_auth_user_created + AFTER INSERT ON auth.users + FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); + +-- ===================================================== +-- Row Level Security +-- ===================================================== + +ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY; + +-- Users can view their own profile +CREATE POLICY "Users can view own profile" + ON public.profiles + FOR SELECT + USING (auth.uid() = id); + +-- Users can update their own profile +CREATE POLICY "Users can update own profile" + ON public.profiles + FOR UPDATE + USING (auth.uid() = id) + WITH CHECK (auth.uid() = id); + +-- Note: No INSERT policy needed - trigger handles creation +-- Note: No DELETE policy - profiles deleted via CASCADE when auth.users deleted + +-- ===================================================== +-- Indexes +-- ===================================================== + +CREATE INDEX idx_profiles_is_premium ON public.profiles(is_premium) WHERE is_premium = TRUE; + +-- ===================================================== +-- Grants +-- ===================================================== + +GRANT SELECT, UPDATE ON public.profiles TO authenticated; diff --git a/supabase/migrations/20260124000000_add_disabled_at.sql b/supabase/migrations/20260124000000_add_disabled_at.sql new file mode 100644 index 0000000..a947dc6 --- /dev/null +++ b/supabase/migrations/20260124000000_add_disabled_at.sql @@ -0,0 +1,15 @@ +-- ===================================================== +-- Add disabled_at column to profiles table +-- ===================================================== +-- Allows soft-disabling accounts instead of hard-deleting, +-- preserving user data and purchases. + +ALTER TABLE public.profiles +ADD COLUMN disabled_at TIMESTAMPTZ DEFAULT NULL; + +COMMENT ON COLUMN public.profiles.disabled_at IS + 'When account was disabled. NULL = active account.'; + +-- Index for querying disabled accounts efficiently +CREATE INDEX idx_profiles_disabled_at ON public.profiles(disabled_at) +WHERE disabled_at IS NOT NULL;