From 3cb4b802d1e4ce5f1c1fbcaa1e62faefbddac4b7 Mon Sep 17 00:00:00 2001 From: Mykyta Ryasny Date: Fri, 23 Jan 2026 01:46:24 +0100 Subject: [PATCH 1/6] Added a functional local development --- .claude/CLAUDE.md | 1127 +++++++++++++++++ .gitignore | 7 +- assets/config/dev.json | 14 + assets/config/prod.json | 14 + help.md | 68 + lib/config/app_config.dart | 94 ++ lib/main.dart | 20 +- pubspec.yaml | 34 +- scripts/setup_launch.dart | 132 ++ supabase/.gitignore | 8 + supabase/config.toml | 384 ++++++ .../20260122223230_remote_schema.sql | 295 +++++ .../20260123020000_create_profiles_table.sql | 104 ++ 13 files changed, 2280 insertions(+), 21 deletions(-) create mode 100644 .claude/CLAUDE.md create mode 100644 assets/config/dev.json create mode 100644 assets/config/prod.json create mode 100644 help.md create mode 100644 lib/config/app_config.dart create mode 100644 scripts/setup_launch.dart create mode 100644 supabase/.gitignore create mode 100644 supabase/config.toml create mode 100644 supabase/migrations/20260122223230_remote_schema.sql create mode 100644 supabase/migrations/20260123020000_create_profiles_table.sql 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/assets/config/dev.json b/assets/config/dev.json new file mode 100644 index 0000000..3b0430a --- /dev/null +++ b/assets/config/dev.json @@ -0,0 +1,14 @@ +{ + "env": "development", + "enable_logging": true, + "supabase": { + "url": "http://127.0.0.1:54321", + "anon_key": "sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH" + }, + "storage": { + "url": "http://127.0.0.1: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/help.md b/help.md new file mode 100644 index 0000000..d8b0298 --- /dev/null +++ b/help.md @@ -0,0 +1,68 @@ +# Local instalation +## 1. Install Supabase CLI (macOS) +```brew install supabase/tap/supabase``` + +## Or via npm (not sure if works) +```npm install -g supabase``` + +## 2. Initialize in your Flutter project +```supabase init``` + +## 3. Link to your existing remote project +```supabase link --project-ref your-db-name``` + +## 4. Pull existing schema from production +```supabase db pull``` + +# Useful commands + +## Start local stack +```supabase start``` + +## Pull schema from production → creates migration files +```supabase db pull``` + +## Make changes locally (via Studio at localhost:54323 or SQL) +## Then generate a migration from your changes +```supabase db diff -f my_new_feature``` + +## Reset local DB to clean state (applies all migrations) +```supabase db reset``` + +## Push migrations to production when ready +```supabase db push``` + +## Stop local stack +```supabase stop``` + +## How to start a project + +After installing all dart dependencies \ +```dart pub get``` + +you will need to check scripts inside pubspec.yaml file. That file contains all usefull scripts to use. To be able to use that scripts you need to activate derry globally or execute \ + +```dart pub global run derry dev``` + +To been able to execute it as it is follow next steps (adapt it to your enviorment) + +## 1. Activate derry globally +```dart pub global activate derry``` + +## 2. Add pub cache to your PATH (for zsh) +```echo 'export PATH="$PATH":"$HOME/.pub-cache/bin"' >> ~/.zshrc``` + +## 3. Reload your shell +```source ~/.zshrc``` + +## 4. Now try to start +```derry dev``` + +# Best solution +You can execute \ +```derry setup``` \ +This command will generate you a .vscode config to been able to launch on your device \ +PD: THIS IS A FAST SETUP \ +For more specific device settings and options you can config this manually. + + diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart new file mode 100644 index 0000000..c71a49b --- /dev/null +++ b/lib/config/app_config.dart @@ -0,0 +1,94 @@ +import 'dart:convert'; +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'; + + /// 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 config = AppConfig( + env: json['env'] as String, + enableLogging: json['enable_logging'] as bool? ?? false, + supabase: SupabaseConfig.fromJson(json['supabase'] as Map), + storage: StorageConfig.fromJson(json['storage'] as Map), + ); + + instance = config; + return config; + } + + /// Log only in development + void log(String message) { + if (enableLogging) { + print('[$env] $message'); + } + } +} 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/pubspec.yaml b/pubspec.yaml index 92e5de1..ca0a4b0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,15 +42,30 @@ 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: + # Development (local Supabase) + dev: flutter run --dart-define=ENV=dev + dev:ios: flutter run --dart-define=ENV=dev -d iPhone + dev:android: flutter run --dart-define=ENV=dev -d sdk + + # Production run (for testing prod config locally) + prod: flutter run --dart-define=ENV=prod + + # Build (always production) + 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 + setup: dart scripts/setup_launch.dart + # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec @@ -62,10 +77,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/setup_launch.dart b/scripts/setup_launch.dart new file mode 100644 index 0000000..e5bcd33 --- /dev/null +++ b/scripts/setup_launch.dart @@ -0,0 +1,132 @@ +#!/usr/bin/env dart +// Generates .vscode/launch.json with correct device IDs for this machine +// Run: dart scripts/setup_launch.dart + +import 'dart:convert'; +import 'dart:io'; + +void main() async { + print('🔍 Finding devices...\n'); + + // Get devices as JSON + final result = await Process.run('flutter', ['devices', '--machine']); + if (result.exitCode != 0) { + print('❌ Failed to get devices: ${result.stderr}'); + exit(1); + } + + final devices = jsonDecode(result.stdout as String) as List; + + // Find iOS and Android devices + String? iosId; + String? androidId; + String? iosName; + String? androidName; + + for (final device in devices) { + final platform = device['targetPlatform'] as String; + final id = device['id'] as String; + final name = device['name'] as String; + + if (platform == 'ios' && iosId == null) { + iosId = id; + iosName = name; + } else if (platform.startsWith('android') && androidId == null) { + androidId = id; + androidName = name; + } + } + + print('📱 iOS: ${iosName ?? "not found"} (${iosId ?? "N/A"})'); + print('🤖 Android: ${androidName ?? "not found"} (${androidId ?? "N/A"})\n'); + + // Generate launch.json + final launchJson = { + 'version': '0.2.0', + 'configurations': [ + { + 'name': 'Dev', + 'request': 'launch', + 'type': 'dart', + 'args': ['--dart-define=ENV=dev'], + }, + { + 'name': 'Prod', + 'request': 'launch', + 'type': 'dart', + 'args': ['--dart-define=ENV=prod'], + }, + if (iosId != null) + { + 'name': 'Dev (iOS)', + 'request': 'launch', + 'type': 'dart', + 'deviceId': iosId, + 'args': ['--dart-define=ENV=dev'], + }, + if (androidId != null) + { + 'name': 'Dev (Android)', + 'request': 'launch', + 'type': 'dart', + 'deviceId': androidId, + 'args': ['--dart-define=ENV=dev'], + }, + if (iosId != null) + { + 'name': 'Prod (iOS)', + 'request': 'launch', + 'type': 'dart', + 'deviceId': iosId, + 'args': ['--dart-define=ENV=prod'], + }, + if (androidId != null) + { + 'name': 'Prod (Android)', + 'request': 'launch', + 'type': 'dart', + 'deviceId': androidId, + 'args': ['--dart-define=ENV=prod'], + }, + { + 'name': 'Dev (Profile)', + 'request': 'launch', + 'type': 'dart', + 'flutterMode': 'profile', + 'args': ['--dart-define=ENV=dev'], + }, + { + 'name': 'Prod (Release)', + 'request': 'launch', + 'type': 'dart', + 'flutterMode': 'release', + 'args': ['--dart-define=ENV=prod'], + }, + ], + 'compounds': [ + if (iosId != null && androidId != null) + { + 'name': 'Dev (iOS + Android)', + 'configurations': ['Dev (iOS)', 'Dev (Android)'], + }, + if (iosId != null && androidId != null) + { + 'name': 'Prod (iOS + Android)', + 'configurations': ['Prod (iOS)', 'Prod (Android)'], + }, + ], + }; + + // Write to file + final vscodeDir = Directory('.vscode'); + if (!vscodeDir.existsSync()) { + vscodeDir.createSync(); + } + + final file = File('.vscode/launch.json'); + final encoder = JsonEncoder.withIndent(' '); + file.writeAsStringSync(encoder.convert(launchJson)); + + print('✅ Generated .vscode/launch.json'); + print(' Restart VS Code or reload window to apply changes.'); +} 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; From fc224ad9411ca22da849404b5618cad29c3b3be0 Mon Sep 17 00:00:00 2001 From: Mykyta Ryasny Date: Fri, 23 Jan 2026 19:59:24 +0100 Subject: [PATCH 2/6] Disable instead of delete account added preserving all the configuration --- help.md | 3 ++ lib/controller/auth_service.dart | 34 +++++++++++++++++-- lib/controller/pages/login_page.dart | 20 ++++++++++- lib/controller/pages/profile_page.dart | 32 ++++++++++++++--- lib/view/auth/profile_scaffold.dart | 10 +++--- lib/view/auth/reenable_account_dialog.dart | 27 +++++++++++++++ .../20260124000000_add_disabled_at.sql | 15 ++++++++ 7 files changed, 129 insertions(+), 12 deletions(-) create mode 100644 lib/view/auth/reenable_account_dialog.dart create mode 100644 supabase/migrations/20260124000000_add_disabled_at.sql diff --git a/help.md b/help.md index d8b0298..d85a646 100644 --- a/help.md +++ b/help.md @@ -29,6 +29,9 @@ ## Reset local DB to clean state (applies all migrations) ```supabase db reset``` +## If you only want to apply migrations without loosing data +```supabase migration up``` + ## Push migrations to production when ready ```supabase db push``` 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/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/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; From 5a5e72eed0299262fcc46de1ed2415ec98849469 Mon Sep 17 00:00:00 2001 From: Mykyta Ryasny Date: Fri, 23 Jan 2026 20:19:49 +0100 Subject: [PATCH 3/6] fix local dev android --- assets/config/dev.json | 2 ++ lib/config/app_config.dart | 24 ++++++++++++++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/assets/config/dev.json b/assets/config/dev.json index 3b0430a..3b331d4 100644 --- a/assets/config/dev.json +++ b/assets/config/dev.json @@ -3,10 +3,12 @@ "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/lib/config/app_config.dart b/lib/config/app_config.dart index c71a49b..e84d4ad 100644 --- a/lib/config/app_config.dart +++ b/lib/config/app_config.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io' show Platform; import 'package:flutter/services.dart'; class SupabaseConfig { @@ -67,6 +68,14 @@ class AppConfig { /// 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( @@ -74,11 +83,22 @@ class AppConfig { ); 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.fromJson(json['supabase'] as Map), - storage: StorageConfig.fromJson(json['storage'] as Map), + 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; From 40cc316b74f67520cfc77e9067dc54eca6705f10 Mon Sep 17 00:00:00 2001 From: Mykyta Ryasny Date: Sun, 25 Jan 2026 02:39:33 +0100 Subject: [PATCH 4/6] Better setup explanation and script for launch everything --- WIP.md | 2 +- help.md | 71 ------ pubspec.yaml | 15 +- scripts/setup_launch.dart | 132 ----------- scripts/start.dart | 476 ++++++++++++++++++++++++++++++++++++++ setup.md | 329 ++++++++++++++++++++++++++ 6 files changed, 812 insertions(+), 213 deletions(-) delete mode 100644 help.md delete mode 100644 scripts/setup_launch.dart create mode 100644 scripts/start.dart create mode 100644 setup.md 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/help.md b/help.md deleted file mode 100644 index d85a646..0000000 --- a/help.md +++ /dev/null @@ -1,71 +0,0 @@ -# Local instalation -## 1. Install Supabase CLI (macOS) -```brew install supabase/tap/supabase``` - -## Or via npm (not sure if works) -```npm install -g supabase``` - -## 2. Initialize in your Flutter project -```supabase init``` - -## 3. Link to your existing remote project -```supabase link --project-ref your-db-name``` - -## 4. Pull existing schema from production -```supabase db pull``` - -# Useful commands - -## Start local stack -```supabase start``` - -## Pull schema from production → creates migration files -```supabase db pull``` - -## Make changes locally (via Studio at localhost:54323 or SQL) -## Then generate a migration from your changes -```supabase db diff -f my_new_feature``` - -## Reset local DB to clean state (applies all migrations) -```supabase db reset``` - -## If you only want to apply migrations without loosing data -```supabase migration up``` - -## Push migrations to production when ready -```supabase db push``` - -## Stop local stack -```supabase stop``` - -## How to start a project - -After installing all dart dependencies \ -```dart pub get``` - -you will need to check scripts inside pubspec.yaml file. That file contains all usefull scripts to use. To be able to use that scripts you need to activate derry globally or execute \ - -```dart pub global run derry dev``` - -To been able to execute it as it is follow next steps (adapt it to your enviorment) - -## 1. Activate derry globally -```dart pub global activate derry``` - -## 2. Add pub cache to your PATH (for zsh) -```echo 'export PATH="$PATH":"$HOME/.pub-cache/bin"' >> ~/.zshrc``` - -## 3. Reload your shell -```source ~/.zshrc``` - -## 4. Now try to start -```derry dev``` - -# Best solution -You can execute \ -```derry setup``` \ -This command will generate you a .vscode config to been able to launch on your device \ -PD: THIS IS A FAST SETUP \ -For more specific device settings and options you can config this manually. - - diff --git a/pubspec.yaml b/pubspec.yaml index ca0a4b0..34a4b14 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -46,15 +46,13 @@ dev_dependencies: test: ^1.25.8 scripts: - # Development (local Supabase) - dev: flutter run --dart-define=ENV=dev - dev:ios: flutter run --dart-define=ENV=dev -d iPhone - dev:android: flutter run --dart-define=ENV=dev -d sdk + # 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 - # Production run (for testing prod config locally) - prod: flutter run --dart-define=ENV=prod - - # Build (always production) + # 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 @@ -64,7 +62,6 @@ scripts: clean: flutter clean && flutter pub get test: flutter test analyze: flutter analyze - setup: dart scripts/setup_launch.dart # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/scripts/setup_launch.dart b/scripts/setup_launch.dart deleted file mode 100644 index e5bcd33..0000000 --- a/scripts/setup_launch.dart +++ /dev/null @@ -1,132 +0,0 @@ -#!/usr/bin/env dart -// Generates .vscode/launch.json with correct device IDs for this machine -// Run: dart scripts/setup_launch.dart - -import 'dart:convert'; -import 'dart:io'; - -void main() async { - print('🔍 Finding devices...\n'); - - // Get devices as JSON - final result = await Process.run('flutter', ['devices', '--machine']); - if (result.exitCode != 0) { - print('❌ Failed to get devices: ${result.stderr}'); - exit(1); - } - - final devices = jsonDecode(result.stdout as String) as List; - - // Find iOS and Android devices - String? iosId; - String? androidId; - String? iosName; - String? androidName; - - for (final device in devices) { - final platform = device['targetPlatform'] as String; - final id = device['id'] as String; - final name = device['name'] as String; - - if (platform == 'ios' && iosId == null) { - iosId = id; - iosName = name; - } else if (platform.startsWith('android') && androidId == null) { - androidId = id; - androidName = name; - } - } - - print('📱 iOS: ${iosName ?? "not found"} (${iosId ?? "N/A"})'); - print('🤖 Android: ${androidName ?? "not found"} (${androidId ?? "N/A"})\n'); - - // Generate launch.json - final launchJson = { - 'version': '0.2.0', - 'configurations': [ - { - 'name': 'Dev', - 'request': 'launch', - 'type': 'dart', - 'args': ['--dart-define=ENV=dev'], - }, - { - 'name': 'Prod', - 'request': 'launch', - 'type': 'dart', - 'args': ['--dart-define=ENV=prod'], - }, - if (iosId != null) - { - 'name': 'Dev (iOS)', - 'request': 'launch', - 'type': 'dart', - 'deviceId': iosId, - 'args': ['--dart-define=ENV=dev'], - }, - if (androidId != null) - { - 'name': 'Dev (Android)', - 'request': 'launch', - 'type': 'dart', - 'deviceId': androidId, - 'args': ['--dart-define=ENV=dev'], - }, - if (iosId != null) - { - 'name': 'Prod (iOS)', - 'request': 'launch', - 'type': 'dart', - 'deviceId': iosId, - 'args': ['--dart-define=ENV=prod'], - }, - if (androidId != null) - { - 'name': 'Prod (Android)', - 'request': 'launch', - 'type': 'dart', - 'deviceId': androidId, - 'args': ['--dart-define=ENV=prod'], - }, - { - 'name': 'Dev (Profile)', - 'request': 'launch', - 'type': 'dart', - 'flutterMode': 'profile', - 'args': ['--dart-define=ENV=dev'], - }, - { - 'name': 'Prod (Release)', - 'request': 'launch', - 'type': 'dart', - 'flutterMode': 'release', - 'args': ['--dart-define=ENV=prod'], - }, - ], - 'compounds': [ - if (iosId != null && androidId != null) - { - 'name': 'Dev (iOS + Android)', - 'configurations': ['Dev (iOS)', 'Dev (Android)'], - }, - if (iosId != null && androidId != null) - { - 'name': 'Prod (iOS + Android)', - 'configurations': ['Prod (iOS)', 'Prod (Android)'], - }, - ], - }; - - // Write to file - final vscodeDir = Directory('.vscode'); - if (!vscodeDir.existsSync()) { - vscodeDir.createSync(); - } - - final file = File('.vscode/launch.json'); - final encoder = JsonEncoder.withIndent(' '); - file.writeAsStringSync(encoder.convert(launchJson)); - - print('✅ Generated .vscode/launch.json'); - print(' Restart VS Code or reload window to apply changes.'); -} diff --git a/scripts/start.dart b/scripts/start.dart new file mode 100644 index 0000000..e81dc36 --- /dev/null +++ b/scripts/start.dart @@ -0,0 +1,476 @@ +#!/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: 60); +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 (parallel for 'both') + String? androidId; + String? iosId; + + if (platform == 'both' && Platform.isMacOS) { + // Run both in parallel + print('Ensuring devices (parallel)...'); + final results = await Future.wait([ + _ensureAndroidDevice(), + _ensureIOSDevice(), + ]); + androidId = results[0]; + iosId = results[1]; + print(' Android ready: $androidId'); + print(' iOS ready: $iosId'); + } else { + if (platform == 'android') { + print('Ensuring Android device...'); + androidId = await _ensureAndroidDevice(); + print(' Android ready: $androidId'); + } + + if (platform == 'ios') { + if (!Platform.isMacOS) { + print('Warning: iOS is only available on macOS'); + exit(1); + } + 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']); + 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(); + for (final device in devices) { + final platform = device['targetPlatform'] as String? ?? ''; + if (platform.startsWith('android')) { + return device['id'] as String; + } + } + + // Get available AVDs + final sdkPath = _getAndroidSdkPath(); + if (sdkPath == null) { + throw Exception('Android SDK not found'); + } + + final emulatorPath = '$sdkPath/emulator/emulator'; + final listResult = await Process.run( + emulatorPath, + ['-list-avds'], + environment: {'ANDROID_HOME': sdkPath, 'ANDROID_SDK_ROOT': sdkPath}, + ); + + var avds = (listResult.stdout as String) + .split('\n') + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); + + // Create AVD if none exist + if (avds.isEmpty) { + print(' Creating Android emulator...'); + await Process.run('flutter', ['emulators', '--create', '--name', 'flutter_emulator']); + avds = ['flutter_emulator']; + } + + // Launch emulator + print(' Launching ${avds.first}...'); + await Process.start( + emulatorPath, + ['-avd', avds.first], + mode: ProcessStartMode.detached, + ); + + // Wait for device + return await _waitForDevice( + 'Android', + () async { + final devices = await _getRunningDevices(); + for (final device in devices) { + final platform = device['targetPlatform'] as String? ?? ''; + if (platform.startsWith('android')) { + return device['id'] as String?; + } + } + return null; + }, + ); +} + +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(); + + while (DateTime.now().difference(startTime) < deviceTimeout) { + final id = await getDeviceId(); + if (id != null) return id; + await Future.delayed(pollInterval); + stdout.write('.'); + } + + throw Exception('Timeout waiting for $platform device'); +} + +String? _getAndroidSdkPath() { + final fromEnv = Platform.environment['ANDROID_HOME'] ?? + Platform.environment['ANDROID_SDK_ROOT']; + if (fromEnv != null) return fromEnv; + + final homeDir = Platform.environment['HOME'] ?? ''; + final paths = [ + '$homeDir/Library/Android/sdk', + '$homeDir/Android/Sdk', + ]; + + for (final path in paths) { + if (Directory(path).existsSync()) return path; + } + + return null; +} + +// ============================================================================ +// 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 ?? 'emulator-5554', + '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 ?? 'emulator-5554', + '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 PowerShell on Windows + await Process.run('powershell', ['-Command', ''' + \$wshell = New-Object -ComObject wscript.shell + \$wshell.AppActivate("Visual Studio Code") + Start-Sleep -Milliseconds 500 + \$wshell.SendKeys("{F5}") + ''']); + } 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 +``` + From 0328f91437b1349275664ee1c91cd3a830d3b55e Mon Sep 17 00:00:00 2001 From: Mykyta Ryasny Date: Sun, 25 Jan 2026 02:43:02 +0100 Subject: [PATCH 5/6] linter --- scripts/start.dart | 107 ++++++++++++++++++++++++--------------------- 1 file changed, 57 insertions(+), 50 deletions(-) diff --git a/scripts/start.dart b/scripts/start.dart index e81dc36..9e9cd7c 100644 --- a/scripts/start.dart +++ b/scripts/start.dart @@ -1,16 +1,16 @@ #!/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 +// 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'; @@ -51,33 +51,21 @@ void main(List args) async { print('Platform: $platform'); print(''); - // Step 2: Ensure devices are running (parallel for 'both') + // Step 2: Ensure devices are running String? androidId; String? iosId; - if (platform == 'both' && Platform.isMacOS) { - // Run both in parallel - print('Ensuring devices (parallel)...'); - final results = await Future.wait([ - _ensureAndroidDevice(), - _ensureIOSDevice(), - ]); - androidId = results[0]; - iosId = results[1]; + if (platform == 'android' || platform == 'both') { + print('Ensuring Android device...'); + androidId = await _ensureAndroidDevice(); print(' Android ready: $androidId'); - print(' iOS ready: $iosId'); - } else { - if (platform == 'android') { - print('Ensuring Android device...'); - androidId = await _ensureAndroidDevice(); - print(' Android ready: $androidId'); - } + } - if (platform == 'ios') { - if (!Platform.isMacOS) { - print('Warning: iOS is only available on macOS'); - exit(1); - } + 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'); @@ -101,7 +89,6 @@ void main(List args) async { print(''); print('Done! Check VS Code for the debug session.'); - } catch (e) { print('Error: $e'); exit(1); @@ -209,7 +196,8 @@ Future _ensureAndroidDevice() async { // Create AVD if none exist if (avds.isEmpty) { print(' Creating Android emulator...'); - await Process.run('flutter', ['emulators', '--create', '--name', 'flutter_emulator']); + await Process.run( + 'flutter', ['emulators', '--create', '--name', 'flutter_emulator']); avds = ['flutter_emulator']; } @@ -249,8 +237,10 @@ Future _ensureIOSDevice() async { } // Get available simulators - final simResult = await Process.run('xcrun', ['simctl', 'list', 'devices', '--json']); - final simJson = jsonDecode(simResult.stdout as String) as Map; + 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 = []; @@ -276,9 +266,12 @@ Future _ensureIOSDevice() async { 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 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'), @@ -286,7 +279,11 @@ Future _ensureIOSDevice() async { ); final createResult = await Process.run('xcrun', [ - 'simctl', 'create', 'Flutter iPhone', 'iPhone 16 Pro', iosRuntime['identifier'] as String, + 'simctl', + 'create', + 'Flutter iPhone', + 'iPhone 16 Pro', + iosRuntime['identifier'] as String, ]); if (createResult.exitCode != 0) { @@ -434,10 +431,14 @@ Future _generateVSCodeConfig({ } 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)'; + 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)'); } @@ -456,19 +457,25 @@ Future _launchVSCodeDebug(String platform) async { ]); } else if (Platform.isLinux) { // Use xdotool on Linux - await Process.run('bash', ['-c', ''' + 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 PowerShell on Windows - await Process.run('powershell', ['-Command', ''' + await Process.run('powershell', [ + '-Command', + ''' \$wshell = New-Object -ComObject wscript.shell \$wshell.AppActivate("Visual Studio Code") Start-Sleep -Milliseconds 500 \$wshell.SendKeys("{F5}") - ''']); + ''' + ]); } else { print(' Note: Auto-launch not supported on this platform.'); print(' Please press F5 in VS Code to start debugging.'); From c05099939b9ceb24fae19c7cdd13edb2fc20d669 Mon Sep 17 00:00:00 2001 From: Mykyta Ryasny Date: Sun, 25 Jan 2026 18:33:15 +0100 Subject: [PATCH 6/6] Fix Windows launch --- pubspec.lock | 16 ++-- scripts/start.dart | 229 ++++++++++++++++++++++++++++++--------------- 2 files changed, 164 insertions(+), 81 deletions(-) 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/scripts/start.dart b/scripts/start.dart index 9e9cd7c..fa54053 100644 --- a/scripts/start.dart +++ b/scripts/start.dart @@ -15,7 +15,7 @@ import 'dart:convert'; import 'dart:io'; -const Duration deviceTimeout = Duration(seconds: 60); +const Duration deviceTimeout = Duration(seconds: 120); const Duration pollInterval = Duration(seconds: 2); void main(List args) async { @@ -149,7 +149,8 @@ Future _promptChoice( // ============================================================================ Future>> _getRunningDevices() async { - final result = await Process.run('flutter', ['devices', '--machine']); + final result = await Process.run('flutter', ['devices', '--machine'], + runInShell: true); if (result.exitCode != 0) return []; final output = result.stdout as String; @@ -167,62 +168,150 @@ Future>> _getRunningDevices() async { 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 AVDs - final sdkPath = _getAndroidSdkPath(); - if (sdkPath == null) { - throw Exception('Android SDK not found'); - } - - final emulatorPath = '$sdkPath/emulator/emulator'; - final listResult = await Process.run( - emulatorPath, - ['-list-avds'], - environment: {'ANDROID_HOME': sdkPath, 'ANDROID_SDK_ROOT': sdkPath}, + // Get available emulators using flutter + final emulatorsResult = await Process.run( + 'flutter', + ['emulators'], + runInShell: true, ); - var avds = (listResult.stdout as String) - .split('\n') - .map((s) => s.trim()) - .where((s) => s.isNotEmpty) - .toList(); + // 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 AVD if none exist - if (avds.isEmpty) { + // Create emulator if none exist + String emulatorId; + if (emulatorIds.isEmpty) { print(' Creating Android emulator...'); await Process.run( - 'flutter', ['emulators', '--create', '--name', 'flutter_emulator']); - avds = ['flutter_emulator']; + 'flutter', ['emulators', '--create', '--name', 'flutter_emulator'], + runInShell: true); + emulatorId = 'flutter_emulator'; + } else { + emulatorId = emulatorIds.first; } - // Launch emulator - print(' Launching ${avds.first}...'); - await Process.start( - emulatorPath, - ['-avd', avds.first], - mode: ProcessStartMode.detached, - ); + // 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 - return await _waitForDevice( - 'Android', - () async { - final devices = await _getRunningDevices(); - for (final device in devices) { - final platform = device['targetPlatform'] as String? ?? ''; - if (platform.startsWith('android')) { - return device['id'] as String?; + // 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; } } - return null; - }, - ); + } + 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 { @@ -324,35 +413,27 @@ Future _waitForDevice( 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) return id; + if (id != null) { + print('\n Device found: $id'); + return id; + } await Future.delayed(pollInterval); - stdout.write('.'); + attempt++; + if (attempt % 5 == 0) { + print(' Still waiting... (${DateTime.now().difference(startTime).inSeconds}s)'); + } else { + stdout.write('.'); + } } throw Exception('Timeout waiting for $platform device'); } -String? _getAndroidSdkPath() { - final fromEnv = Platform.environment['ANDROID_HOME'] ?? - Platform.environment['ANDROID_SDK_ROOT']; - if (fromEnv != null) return fromEnv; - - final homeDir = Platform.environment['HOME'] ?? ''; - final paths = [ - '$homeDir/Library/Android/sdk', - '$homeDir/Android/Sdk', - ]; - - for (final path in paths) { - if (Directory(path).existsSync()) return path; - } - - return null; -} - // ============================================================================ // VS Code Configuration // ============================================================================ @@ -379,7 +460,7 @@ Future _generateVSCodeConfig({ 'name': 'Flutter (Android)', 'type': 'dart', 'request': 'launch', - 'deviceId': androidId ?? 'emulator-5554', + 'deviceId': androidId!, 'args': ['--dart-define=ENV=$env'], 'flutterMode': mode, }); @@ -400,7 +481,7 @@ Future _generateVSCodeConfig({ 'type': 'dart', 'request': 'launch', 'presentation': {'hidden': true}, - 'deviceId': androidId ?? 'emulator-5554', + 'deviceId': androidId!, 'args': ['--dart-define=ENV=$env'], 'flutterMode': mode, }); @@ -466,16 +547,18 @@ Future _launchVSCodeDebug(String platform) async { ''' ]); } else if (Platform.isWindows) { - // Use PowerShell on Windows - await Process.run('powershell', [ - '-Command', - ''' - \$wshell = New-Object -ComObject wscript.shell - \$wshell.AppActivate("Visual Studio Code") - Start-Sleep -Milliseconds 500 - \$wshell.SendKeys("{F5}") - ''' - ]); + // 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.');