diff --git a/.github/workflows/test-and-build.yml b/.github/workflows/test-and-build.yml index 89f172e..2d3a6df 100644 --- a/.github/workflows/test-and-build.yml +++ b/.github/workflows/test-and-build.yml @@ -17,22 +17,23 @@ jobs: contents: read checks: write pull-requests: write + security-events: write steps: - name: Checkout Code - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2 - name: Setup Java - uses: actions/setup-java@cd89f11832ad0855263829ad3623d1d31a929307 # v3.13.0 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.4.0 with: distribution: 'zulu' java-version: '17' cache: 'gradle' - name: Setup Flutter - uses: subosito/flutter-action@44ae346459a5c099ba318147f2f297c0d74a0db2 # v2.12.0 + uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.16.0 with: - flutter-version: '3.24.3' + flutter-version: '3.41.9' channel: 'stable' cache: true @@ -49,14 +50,14 @@ jobs: run: flutter test --coverage - name: Upload coverage to Codecov - uses: codecov/codecov-action@eaaf4bedf32dbdc6b720b63067d99c4d77d6047d # v3.1.4 + uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} file: ./coverage/lcov.info fail_ci_if_error: false - name: Archive coverage reports - uses: actions/upload-artifact@c7d193f32ed247398f1ef51e04acc37d996a730c # v4.1.0 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.4.3 with: name: code-coverage path: coverage/ @@ -71,19 +72,19 @@ jobs: steps: - name: Checkout Code - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2 - name: Setup Java - uses: actions/setup-java@cd89f11832ad0855263829ad3623d1d31a929307 # v3.13.0 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.4.0 with: distribution: 'zulu' java-version: '17' cache: 'gradle' - name: Setup Flutter - uses: subosito/flutter-action@44ae346459a5c099ba318147f2f297c0d74a0db2 # v2.12.0 + uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.16.0 with: - flutter-version: '3.24.3' + flutter-version: '3.41.9' channel: 'stable' cache: true @@ -94,7 +95,7 @@ jobs: run: flutter build apk --debug --dart-define=SUPABASE_URL=${{ secrets.SUPABASE_URL }} --dart-define=SUPABASE_ANON_KEY=${{ secrets.SUPABASE_ANON_KEY }} - name: Upload APK - uses: actions/upload-artifact@c7d193f32ed247398f1ef51e04acc37d996a730c # v4.1.0 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.4.3 with: name: release-apk path: build/app/outputs/flutter-apk/app-debug.apk @@ -109,12 +110,12 @@ jobs: steps: - name: Checkout Code - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2 - name: Setup Flutter - uses: subosito/flutter-action@44ae346459a5c099ba318147f2f297c0d74a0db2 # v2.12.0 + uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.16.0 with: - flutter-version: '3.24.3' + flutter-version: '3.41.9' channel: 'stable' cache: true @@ -125,7 +126,7 @@ jobs: run: flutter build ios --no-codesign --debug --dart-define=SUPABASE_URL=${{ secrets.SUPABASE_URL }} --dart-define=SUPABASE_ANON_KEY=${{ secrets.SUPABASE_ANON_KEY }} - name: Upload Runner - uses: actions/upload-artifact@c7d193f32ed247398f1ef51e04acc37d996a730c # v4.1.0 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.4.3 with: name: ios-build path: build/ios/iphoneos/Runner.app @@ -139,12 +140,12 @@ jobs: steps: - name: Checkout Code - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@ccf91b7d19760778f69e6b54a32057635678b871 # v3.25.3 + uses: github/codeql-action/init@0daab03d71ff584ef619d027a3fd9146679c5d84 # v3.27.6 with: languages: javascript, python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@ccf91b7d19760778f69e6b54a32057635678b871 # v3.25.3 + uses: github/codeql-action/analyze@0daab03d71ff584ef619d027a3fd9146679c5d84 # v3.27.6 diff --git a/.gitignore b/.gitignore index 5f72c48..b8b3a43 100755 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,5 @@ dist/petsphere-e6d8abd0.ipa /android/gradle.properties /scratch /.kiro +.claude/settings.local.json + diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html index 7c50449..f281836 100644 --- a/android/build/reports/problems/problems-report.html +++ b/android/build/reports/problems/problems-report.html @@ -650,7 +650,7 @@ diff --git a/lib/controllers/connectivity_controller.dart b/lib/controllers/connectivity_controller.dart index f2160ef..0b7d1da 100644 --- a/lib/controllers/connectivity_controller.dart +++ b/lib/controllers/connectivity_controller.dart @@ -13,25 +13,18 @@ final connectivityServiceProvider = Provider((ref) { /// Stream of connectivity status changes (Broadcast stream for multiple listeners) final connectivityStatusProvider = StreamProvider((ref) { final service = ref.watch(connectivityServiceProvider); - return service.statusStream.asBroadcastStream(); + return service.statusStream; }); /// Convenience: whether device is currently online +/// Optimized using .select to only rebuild when the status changes to/from online final isOnlineProvider = Provider((ref) { - final stream = ref.watch(connectivityStatusProvider); - return stream.whenData((status) => status == ConnectivityStatus.online) - .maybeWhen( - data: (isOnline) => isOnline, - orElse: () => false, - ); + final status = ref.watch(connectivityStatusProvider).asData?.value ?? ConnectivityStatus.unknown; + return status == ConnectivityStatus.online; }); /// Convenience: whether device is currently offline final isOfflineProvider = Provider((ref) { - final stream = ref.watch(connectivityStatusProvider); - return stream.whenData((status) => status == ConnectivityStatus.offline) - .maybeWhen( - data: (isOffline) => isOffline, - orElse: () => false, - ); + final status = ref.watch(connectivityStatusProvider).asData?.value ?? ConnectivityStatus.unknown; + return status == ConnectivityStatus.offline; }); diff --git a/lib/models/post_model.dart b/lib/models/post_model.dart index c651160..2f690d0 100755 --- a/lib/models/post_model.dart +++ b/lib/models/post_model.dart @@ -36,6 +36,10 @@ class CommentModel { 'pet_id': petId, 'text': text, 'created_at': createdAt.toIso8601String(), + 'pets': { + 'name': petName, + 'profile_image_url': petProfileImageUrl, + }, }; } diff --git a/lib/repositories/auth_repository.dart b/lib/repositories/auth_repository.dart index 9dbbaf5..94b4509 100644 --- a/lib/repositories/auth_repository.dart +++ b/lib/repositories/auth_repository.dart @@ -1,5 +1,7 @@ import 'dart:io'; +import 'dart:developer'; import 'package:supabase_flutter/supabase_flutter.dart'; + import '../models/user_model.dart'; import '../utils/supabase_config.dart'; @@ -41,11 +43,15 @@ class AuthRepository { 'name': name, }); } catch (e) { - // Clean up by signing out the user since profile creation failed. - // NOTE: Ideally, we would delete the auth user here to allow re-signup with the same email. - // However, deleting a user requires admin privileges (Service Role key), which should - // NOT be embedded in the client app. - // TODO: Implement a Supabase Edge Function 'delete-self' or similar to handle this rollback. + // Clean up by deleting the auth user and signing out since profile creation failed. + // This allows the user to try again with the same email. + try { + await supabase.functions.invoke('delete-self'); + } catch (rollbackError) { + // Log rollback error but proceed with throwing the original error + log('Rollback failed', error: rollbackError); + } + await supabase.auth.signOut(); throw Exception( 'Failed to create your profile. Please try signing up again. If the problem persists, contact support.'); diff --git a/lib/repositories/offline_feed_repository.dart b/lib/repositories/offline_feed_repository.dart index aaf4218..3f733bc 100644 --- a/lib/repositories/offline_feed_repository.dart +++ b/lib/repositories/offline_feed_repository.dart @@ -35,7 +35,7 @@ class OfflineFeedRepository { if (_connectivity.isOffline) { final cached = _cache.getCachedFeedPosts(); if (cached != null && cached.isNotEmpty) { - return cached.map((json) => PostModel.fromJson(json)).toList(); + return cached.map((json) => PostModel.fromJson(json as Map)).toList(); } // If offline and no cache, throw error throw Exception('No cached posts available and device is offline'); @@ -45,21 +45,21 @@ class OfflineFeedRepository { if (_cache.isFeedPostsFresh(_postsCacheTTL)) { final cached = _cache.getCachedFeedPosts(); if (cached != null) { - return cached.map((json) => PostModel.fromJson(json)).toList(); + return cached.map((json) => PostModel.fromJson(json as Map)).toList(); } } // Cache is stale or missing, fetch from network try { final posts = await _feedRepository.fetchPosts(); - // Note: We cache the model objects; they'll be JSON-serialized by saveJson - await _cache.cacheFeedPosts(posts); + // Convert models to JSON maps for persistence + await _cache.cacheFeedPosts(posts.map((p) => p.toJson()).toList()); return posts; } catch (e) { // Network error - try returning cached data if available final cached = _cache.getCachedFeedPosts(); if (cached != null && cached.isNotEmpty) { - return cached.map((json) => PostModel.fromJson(json)).toList(); + return cached.map((json) => PostModel.fromJson(json as Map)).toList(); } rethrow; } @@ -76,7 +76,7 @@ class OfflineFeedRepository { if (cached != null) { for (final json in cached) { if (json['id'] == postId) { - return PostModel.fromJson(json); + return PostModel.fromJson(json as Map); } } } @@ -91,7 +91,7 @@ class OfflineFeedRepository { if (cached != null) { for (final json in cached) { if (json['id'] == postId) { - return PostModel.fromJson(json); + return PostModel.fromJson(json as Map); } } } diff --git a/lib/repositories/offline_marketplace_repository.dart b/lib/repositories/offline_marketplace_repository.dart index f09c0ef..25cc57b 100644 --- a/lib/repositories/offline_marketplace_repository.dart +++ b/lib/repositories/offline_marketplace_repository.dart @@ -35,7 +35,7 @@ class OfflineMarketplaceRepository { if (_connectivity.isOffline) { final cached = _cache.getCachedProducts(); if (cached != null && cached.isNotEmpty) { - return cached.map((json) => ProductModel.fromJson(json)).toList(); + return cached.map((json) => ProductModel.fromJson(json as Map)).toList(); } throw Exception('No cached products available and device is offline'); } @@ -44,7 +44,7 @@ class OfflineMarketplaceRepository { if (_cache.isProductsFresh(_productsCacheTTL)) { final cached = _cache.getCachedProducts(); if (cached != null) { - return cached.map((json) => ProductModel.fromJson(json)).toList(); + return cached.map((json) => ProductModel.fromJson(json as Map)).toList(); } } @@ -62,7 +62,7 @@ class OfflineMarketplaceRepository { // Network error - try cache as fallback final cached = _cache.getCachedProducts(); if (cached != null && cached.isNotEmpty) { - return cached.map((json) => ProductModel.fromJson(json)).toList(); + return cached.map((json) => ProductModel.fromJson(json as Map)).toList(); } rethrow; } @@ -79,7 +79,7 @@ class OfflineMarketplaceRepository { if (cached != null) { for (final json in cached) { if (json['id'] == id) { - return ProductModel.fromJson(json); + return ProductModel.fromJson(json as Map); } } } @@ -94,7 +94,7 @@ class OfflineMarketplaceRepository { if (cached != null) { for (final json in cached) { if (json['id'] == id) { - return ProductModel.fromJson(json); + return ProductModel.fromJson(json as Map); } } } diff --git a/lib/utils/health_improvements.dart b/lib/utils/health_improvements.dart index bc95fe2..36cffbf 100644 --- a/lib/utils/health_improvements.dart +++ b/lib/utils/health_improvements.dart @@ -118,13 +118,15 @@ bool areMedicationDosesLow( return coveredDays.length < daysThreshold; } -/// Get overdue medication doses +/// Get overdue medication doses (past scheduled time + 1 hour grace period) List getOverdueDoses( - List doses, -) { + List doses, { + Duration grace = const Duration(hours: 1), +}) { final now = DateTime.now(); return doses.where((d) { - return d.givenAt == null && d.scheduledFor.isBefore(now); + // Only overdue if not given AND now is past (scheduled time + grace) + return d.givenAt == null && now.isAfter(d.scheduledFor.add(grace)); }).toList(); } diff --git a/supabase/functions/delete-self/index.ts b/supabase/functions/delete-self/index.ts new file mode 100644 index 0000000..8c63855 --- /dev/null +++ b/supabase/functions/delete-self/index.ts @@ -0,0 +1,30 @@ +import { serve } from "https://deno.land/std@0.168.0/http/server.ts" +import { createClient } from "https://esm.sh/@supabase/supabase-js@2" + +serve(async (req) => { + const supabaseClient = createClient( + Deno.env.get('SUPABASE_URL') ?? '', + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '', + { auth: { persistSession: false } } + ) + + // Get the JWT from the request + const authHeader = req.headers.get('Authorization') + if (!authHeader) { + return new Response(JSON.stringify({ error: 'No authorization header' }), { status: 401 }) + } + + // Get the user from the JWT + const { data: { user }, error: authError } = await supabaseClient.auth.getUser(authHeader.replace('Bearer ', '')) + if (authError || !user) { + return new Response(JSON.stringify({ error: 'Invalid token' }), { status: 401 }) + } + + // Delete the user using service role + const { error: deleteError } = await supabaseClient.auth.admin.deleteUser(user.id) + if (deleteError) { + return new Response(JSON.stringify({ error: deleteError.message }), { status: 500 }) + } + + return new Response(JSON.stringify({ message: 'User deleted successfully' }), { status: 200 }) +}) diff --git a/web/flutter_bootstrap.js b/web/flutter_bootstrap.js.bak similarity index 100% rename from web/flutter_bootstrap.js rename to web/flutter_bootstrap.js.bak