mirror of
https://github.com/Dvorinka/1356.git
synced 2026-06-04 12:02:56 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ab2773f98 | |||
| 7b7ed0083f | |||
| 9f44fd57f7 | |||
| 2aa4c0721f |
@@ -0,0 +1,121 @@
|
||||
# Flutter Development Setup Guide
|
||||
|
||||
## Quick Start (Linux Machine)
|
||||
|
||||
1. **Copy project to Linux machine:**
|
||||
```bash
|
||||
# If using scp from Windows to Linux
|
||||
scp -r w:\Downloads\PROG+HTML\1356 user@linux-machine:/path/to/
|
||||
|
||||
# Or use rsync
|
||||
rsync -av /w/Downloads/PROG+HTML/1356/ user@linux-machine:/path/to/1356/
|
||||
```
|
||||
|
||||
2. **Navigate to project:**
|
||||
```bash
|
||||
cd /path/to/1356/lifetimer
|
||||
```
|
||||
|
||||
3. **Install dependencies:**
|
||||
```bash
|
||||
flutter pub get
|
||||
```
|
||||
|
||||
4. **Run the app:**
|
||||
```bash
|
||||
# With emulator
|
||||
flutter run
|
||||
|
||||
# Or build APK
|
||||
flutter build apk --debug
|
||||
```
|
||||
|
||||
## Changes Made Summary
|
||||
|
||||
### ✅ Fixed Issues:
|
||||
1. **JSON Coercion Error**: Added null safety in `mistral_ai_service.dart`
|
||||
2. **Privacy Mode**: Changed default to `false` for better AI personalization
|
||||
3. **Account Creation**: Added optional height/weight fields
|
||||
4. **AI Formatting**: Added markdown rendering for better text display
|
||||
5. **Loading Status**: Added "AI is thinking..." indicator in chat
|
||||
|
||||
### 📦 Dependencies Added:
|
||||
- `flutter_markdown: ^0.7.3` for formatted AI responses
|
||||
|
||||
### 🔧 Modified Files:
|
||||
- `lib/features/ai_chat/application/ai_chat_controller.dart`
|
||||
- `lib/features/ai_chat/presentation/ai_chat_screen.dart`
|
||||
- `lib/data/services/mistral_ai_service.dart`
|
||||
- `lib/data/models/user_model.dart`
|
||||
- `lib/features/auth/presentation/sign_up_screen.dart`
|
||||
- `lib/data/repositories/auth_repository.dart`
|
||||
- `lib/features/auth/application/auth_controller.dart`
|
||||
- `pubspec.yaml`
|
||||
|
||||
## Testing the Changes
|
||||
|
||||
### On Linux Machine:
|
||||
```bash
|
||||
# 1. Get dependencies
|
||||
flutter pub get
|
||||
|
||||
# 2. Check for any analysis issues
|
||||
flutter analyze
|
||||
|
||||
# 3. Run tests
|
||||
flutter test
|
||||
|
||||
# 4. Run the app
|
||||
flutter run
|
||||
|
||||
# 5. Or build for testing
|
||||
flutter build apk --debug
|
||||
```
|
||||
|
||||
### Manual Testing Checklist:
|
||||
- [ ] Sign up with optional height/weight fields
|
||||
- [ ] Verify AI chat shows formatted responses
|
||||
- [ ] Check loading indicator appears when AI responds
|
||||
- [ ] Confirm privacy mode is off by default
|
||||
- [ ] Test that JSON errors no longer occur
|
||||
|
||||
## Flutter Installation (Linux)
|
||||
|
||||
If you need to install Flutter on Linux:
|
||||
|
||||
```bash
|
||||
# Download Flutter
|
||||
wget https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.19.6-stable.tar.xz
|
||||
|
||||
# Extract
|
||||
tar xf flutter_linux_3.19.6-stable.tar.xz
|
||||
|
||||
# Add to PATH
|
||||
export PATH="$PATH:`pwd`/flutter/bin"
|
||||
|
||||
# Verify installation
|
||||
flutter doctor
|
||||
|
||||
# Install dependencies (Ubuntu/Debian)
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y curl git unzip xz-utils libglu1-mesa
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
```
|
||||
lifetimer/
|
||||
├── lib/
|
||||
│ ├── features/
|
||||
│ │ ├── ai_chat/
|
||||
│ │ ├── auth/
|
||||
│ │ └── ...
|
||||
│ ├── data/
|
||||
│ │ ├── models/
|
||||
│ │ ├── services/
|
||||
│ │ └── ...
|
||||
│ └── ...
|
||||
├── pubspec.yaml
|
||||
└── ...
|
||||
```
|
||||
|
||||
The changes are ready to test once you run them on your Linux machine!
|
||||
@@ -0,0 +1,110 @@
|
||||
# Fixing PostgrestException: Row-Level Security Policy Violation
|
||||
|
||||
## Problem Analysis
|
||||
|
||||
The error "new row violates row-level security policy for table 'users'" occurs when:
|
||||
1. User successfully authenticates via Supabase Auth
|
||||
2. App attempts to create a user profile in the `users` table
|
||||
3. RLS policies prevent the newly authenticated user from inserting their own profile
|
||||
|
||||
## Root Cause
|
||||
|
||||
The issue stems from restrictive RLS policies on the `users` table that don't allow newly authenticated users to insert their own profile records. This is a common issue when RLS is enabled but proper policies aren't in place.
|
||||
|
||||
## Solution Implemented
|
||||
|
||||
### 1. Code Changes
|
||||
|
||||
#### Enhanced Error Handling in Auth Repository
|
||||
- Modified `_createUserProfile()` to gracefully handle RLS violations
|
||||
- Added fallback to service role client for admin operations
|
||||
- Implemented graceful degradation to auth metadata if database operations fail
|
||||
|
||||
#### Service Role Client Support
|
||||
- Added `getServiceRoleClient()` function in `supabase_client.dart`
|
||||
- Provides elevated privileges for user profile creation
|
||||
- Falls back to regular client if service role key is unavailable
|
||||
|
||||
### 2. Database RLS Policies Needed
|
||||
|
||||
To properly fix this issue, create the following RLS policies in your Supabase database:
|
||||
|
||||
```sql
|
||||
-- Policy to allow users to insert their own profile
|
||||
CREATE POLICY "Users can insert their own profile" ON users
|
||||
FOR INSERT WITH CHECK (auth.uid() = id);
|
||||
|
||||
-- Policy to allow users to view their own profile
|
||||
CREATE POLICY "Users can view own profile" ON users
|
||||
FOR SELECT USING (auth.uid() = id);
|
||||
|
||||
-- Policy to allow users to update their own profile
|
||||
CREATE POLICY "Users can update own profile" ON users
|
||||
FOR UPDATE USING (auth.uid() = id);
|
||||
|
||||
-- Policy to allow authenticated users to view public profiles
|
||||
CREATE POLICY "Public profiles are viewable by authenticated users" ON users
|
||||
FOR SELECT USING (is_public_profile = true);
|
||||
```
|
||||
|
||||
### 3. Environment Configuration
|
||||
|
||||
Add service role key to your environment (for development only):
|
||||
|
||||
```bash
|
||||
# In .env file
|
||||
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key-here
|
||||
|
||||
# When running the app
|
||||
flutter run --dart-define-from-file=.env
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Error Handling Flow
|
||||
|
||||
1. **First Attempt**: Try creating profile with regular client
|
||||
2. **Fallback**: If RLS blocks it, try with service role client
|
||||
3. **Graceful Degradation**: If both fail, create user profile from auth metadata
|
||||
|
||||
### Benefits
|
||||
|
||||
- **Non-breaking**: App continues to work even with restrictive RLS
|
||||
- **Secure**: Uses service role client only when necessary
|
||||
- **Flexible**: Handles various database configurations
|
||||
- **User-friendly**: No sign-up failures due to RLS issues
|
||||
|
||||
## Testing the Fix
|
||||
|
||||
1. **Test with RLS enabled**: Verify sign-up works with restrictive policies
|
||||
2. **Test without RLS**: Ensure backward compatibility
|
||||
3. **Test service role**: Verify elevated privileges work when configured
|
||||
4. **Test fallback**: Confirm graceful degradation works
|
||||
|
||||
## Production Considerations
|
||||
|
||||
- Service role key should be handled server-side via Edge Functions
|
||||
- Consider implementing a dedicated API endpoint for user profile creation
|
||||
- Monitor RLS policy violations in production
|
||||
- Implement proper logging for debugging RLS issues
|
||||
|
||||
## Alternative Solutions
|
||||
|
||||
If you prefer a server-side approach:
|
||||
|
||||
1. **Supabase Edge Function**: Create user profile via server function
|
||||
2. **Database Trigger**: Auto-create profile on auth.user insert
|
||||
3. **RPC Function**: Call database function with proper privileges
|
||||
|
||||
## Monitoring
|
||||
|
||||
Add logging to track RLS violations:
|
||||
```dart
|
||||
} catch (e) {
|
||||
// Log the RLS violation for monitoring
|
||||
print('RLS policy violation during user profile creation: $e');
|
||||
// Continue with fallback...
|
||||
}
|
||||
```
|
||||
|
||||
This fix ensures robust user registration while maintaining security through RLS policies.
|
||||
@@ -1,7 +1,13 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<application
|
||||
android:label="lifetimer"
|
||||
android:label="1356"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ class NextCountdownWidgetProvider : HomeWidgetProvider() {
|
||||
) {
|
||||
appWidgetIds.forEach { widgetId ->
|
||||
val title = widgetData.getString("next_title", "Next goal")
|
||||
val subtitle = widgetData.getString("next_subtitle", "Open Lifetimer to see details")
|
||||
val subtitle = widgetData.getString("next_subtitle", "Open 1356 to see details")
|
||||
val timeLeft = widgetData.getString("next_time_left", "0 days left")
|
||||
|
||||
val views = RemoteViews(context.packageName, R.layout.next_countdown_widget).apply {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#1E1E1E" />
|
||||
<corners android:radius="16dp" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#33FFFFFF" />
|
||||
</shape>
|
||||
@@ -2,7 +2,8 @@
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#121212"
|
||||
android:background="#1A1A1A"
|
||||
android:backgroundTint="#2A2A2A"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="vertical"
|
||||
android:padding="12dp">
|
||||
@@ -13,6 +14,7 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Next goal"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textStyle="bold"
|
||||
android:textSize="16sp"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end" />
|
||||
@@ -23,7 +25,8 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="0 days left"
|
||||
android:textColor="#FFCC66"
|
||||
android:textColor="#4CAF50"
|
||||
android:textStyle="bold"
|
||||
android:textSize="20sp"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end" />
|
||||
@@ -33,7 +36,7 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:text="Open Lifetimer to see details"
|
||||
android:text="Open 1356 to see details"
|
||||
android:textColor="#B3FFFFFF"
|
||||
android:textSize="12sp"
|
||||
android:maxLines="2"
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/widget_background"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<!-- 1356 Branding -->
|
||||
<TextView
|
||||
android:id="@+id/text_brand"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="1356"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold"
|
||||
android:background="#4CAF50"
|
||||
android:paddingHorizontal="8dp"
|
||||
android:paddingVertical="4dp"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<!-- Days Remaining -->
|
||||
<TextView
|
||||
android:id="@+id/text_days"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="1356"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="36sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
<!-- Label -->
|
||||
<TextView
|
||||
android:id="@+id/text_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="days remaining"
|
||||
android:textColor="#B3FFFFFF"
|
||||
android:textSize="12sp"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<ProgressBar
|
||||
android:id="@+id/progress_bar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="4dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
style="?android:attr/progressBarStyleHorizontal"
|
||||
android:progressTint="#4CAF50"
|
||||
android:progressBackgroundTint="#33FFFFFF" />
|
||||
|
||||
<!-- Progress Text -->
|
||||
<TextView
|
||||
android:id="@+id/text_progress"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="0% Complete"
|
||||
android:textColor="#B3FFFFFF"
|
||||
android:textSize="10sp" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">1356</string>
|
||||
|
||||
<!-- Google Sign-In Configuration -->
|
||||
<string name="default_web_client_id" translatable="false">YOUR_WEB_CLIENT_ID_HERE</string>
|
||||
</resources>
|
||||
@@ -5,7 +5,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Lifetimer</string>
|
||||
<string>1356</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -13,7 +13,7 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>lifetimer</string>
|
||||
<string>1356</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
@@ -52,7 +52,7 @@
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>lifetimer</string>
|
||||
<string>1356</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
|
||||
@@ -11,11 +11,25 @@ Future<void> bootstrap() async {
|
||||
await HomeWidget.setAppGroupId(Env.iosAppGroupId);
|
||||
}
|
||||
|
||||
// Only initialize Supabase if we have valid credentials
|
||||
if (Env.supabaseUrl.isNotEmpty &&
|
||||
Env.supabaseUrl != 'https://your-project.supabase.co' &&
|
||||
Env.supabaseAnonKey.isNotEmpty &&
|
||||
Env.supabaseAnonKey != 'your-anon-key') {
|
||||
try {
|
||||
await Supabase.initialize(
|
||||
url: Env.supabaseUrl,
|
||||
anonKey: Env.supabaseAnonKey,
|
||||
debug: true,
|
||||
);
|
||||
|
||||
initializeSupabaseClient();
|
||||
} catch (e) {
|
||||
// If Supabase initialization fails, continue without it
|
||||
print('Warning: Supabase initialization failed: $e');
|
||||
print('App will run in offline mode');
|
||||
}
|
||||
} else {
|
||||
print('Warning: Valid Supabase credentials not provided');
|
||||
print('App will run in offline mode');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,64 @@
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
|
||||
|
||||
void initializeSupabaseClient() {
|
||||
// Additional client setup if needed
|
||||
// For now, we use the default Supabase.instance.client
|
||||
}
|
||||
|
||||
SupabaseClient get supabaseClient => Supabase.instance.client;
|
||||
supabase.SupabaseClient? get supabaseClient {
|
||||
try {
|
||||
return supabase.Supabase.instance.client;
|
||||
} catch (e) {
|
||||
// Supabase not initialized
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
bool get isSupabaseInitialized => supabaseClient != null;
|
||||
|
||||
supabase.User? get currentSupabaseUser {
|
||||
final client = supabaseClient;
|
||||
return client?.auth.currentUser;
|
||||
}
|
||||
|
||||
String? get currentSupabaseUserId => currentSupabaseUser?.id;
|
||||
|
||||
String? get currentSupabaseUserEmail => currentSupabaseUser?.email;
|
||||
|
||||
// Service role client for admin operations (like creating user profiles)
|
||||
// This should be used server-side or with proper security measures
|
||||
supabase.SupabaseClient? _serviceRoleClient;
|
||||
|
||||
supabase.SupabaseClient getServiceRoleClient() {
|
||||
if (_serviceRoleClient != null) return _serviceRoleClient!;
|
||||
|
||||
// Note: In a production app, the service role key should be stored securely
|
||||
// This is typically handled server-side via Edge Functions or similar
|
||||
// For now, we'll fall back to the regular client if service role is not available
|
||||
try {
|
||||
const serviceRoleKey = String.fromEnvironment('SUPABASE_SERVICE_ROLE_KEY');
|
||||
const url = String.fromEnvironment('SUPABASE_URL');
|
||||
|
||||
if (serviceRoleKey.isNotEmpty && url.isNotEmpty) {
|
||||
_serviceRoleClient = supabase.SupabaseClient(url, serviceRoleKey);
|
||||
return _serviceRoleClient!;
|
||||
}
|
||||
} catch (e) {
|
||||
// Service role key not available, will use regular client
|
||||
}
|
||||
|
||||
final client = supabaseClient;
|
||||
if (client != null) {
|
||||
return client;
|
||||
}
|
||||
|
||||
// If no client is available, throw an exception
|
||||
throw Exception('Supabase client not initialized');
|
||||
}
|
||||
|
||||
Future<void> signOutCurrentSupabaseUser() async {
|
||||
final client = supabaseClient;
|
||||
if (client == null) return;
|
||||
|
||||
await client.auth.signOut();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
enum Gender {
|
||||
male,
|
||||
female,
|
||||
nonBinary,
|
||||
preferNotToSay;
|
||||
|
||||
static Gender fromString(String? value) {
|
||||
switch (value) {
|
||||
case 'male':
|
||||
return Gender.male;
|
||||
case 'female':
|
||||
return Gender.female;
|
||||
case 'non_binary':
|
||||
return Gender.nonBinary;
|
||||
case 'prefer_not_to_say':
|
||||
return Gender.preferNotToSay;
|
||||
default:
|
||||
return Gender.preferNotToSay;
|
||||
}
|
||||
}
|
||||
|
||||
String toDatabaseString() {
|
||||
switch (this) {
|
||||
case Gender.male:
|
||||
return 'male';
|
||||
case Gender.female:
|
||||
return 'female';
|
||||
case Gender.nonBinary:
|
||||
return 'non_binary';
|
||||
case Gender.preferNotToSay:
|
||||
return 'prefer_not_to_say';
|
||||
}
|
||||
}
|
||||
|
||||
String get displayName {
|
||||
switch (this) {
|
||||
case Gender.male:
|
||||
return 'Male';
|
||||
case Gender.female:
|
||||
return 'Female';
|
||||
case Gender.nonBinary:
|
||||
return 'Non-binary';
|
||||
case Gender.preferNotToSay:
|
||||
return 'Prefer not to say';
|
||||
}
|
||||
}
|
||||
|
||||
String get emoji {
|
||||
switch (this) {
|
||||
case Gender.male:
|
||||
return '👨';
|
||||
case Gender.female:
|
||||
return '👩';
|
||||
case Gender.nonBinary:
|
||||
return '🧑';
|
||||
case Gender.preferNotToSay:
|
||||
return '👤';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum HeightUnit {
|
||||
metric('cm', 'cm'),
|
||||
imperial('ft/in', 'ft/in');
|
||||
|
||||
const HeightUnit(this.code, this.displayName);
|
||||
final String code;
|
||||
final String displayName;
|
||||
}
|
||||
|
||||
enum WeightUnit {
|
||||
metric('kg', 'kg'),
|
||||
imperial('lbs', 'lbs');
|
||||
|
||||
const WeightUnit(this.code, this.displayName);
|
||||
final String code;
|
||||
final String displayName;
|
||||
}
|
||||
|
||||
class UnitConversionUtils {
|
||||
// Height conversions
|
||||
static double cmToInches(double cm) => cm / 2.54;
|
||||
static double inchesToCm(double inches) => inches * 2.54;
|
||||
|
||||
static String cmToFeetInches(double cm) {
|
||||
final totalInches = cmToInches(cm);
|
||||
final feet = (totalInches / 12).floor();
|
||||
final inches = (totalInches % 12).round();
|
||||
return "${feet}'${inches}\"";
|
||||
}
|
||||
|
||||
static double feetInchesToCm(String feetInches) {
|
||||
final parts = feetInches.replaceAll('"', '').replaceAll("'", ' ').split(' ');
|
||||
final feet = double.tryParse(parts[0]) ?? 0;
|
||||
final inches = double.tryParse(parts.length > 1 ? parts[1] : '0') ?? 0;
|
||||
return inchesToCm(feet * 12 + inches);
|
||||
}
|
||||
|
||||
// Weight conversions
|
||||
static double kgToLbs(double kg) => kg * 2.20462;
|
||||
static double lbsToKg(double lbs) => lbs / 2.20462;
|
||||
|
||||
// BMI calculation
|
||||
static double calculateBmi(double weightKg, double heightCm) {
|
||||
if (weightKg <= 0 || heightCm <= 0) return 0;
|
||||
final heightM = heightCm / 100;
|
||||
return weightKg / (heightM * heightM);
|
||||
}
|
||||
|
||||
static String getBmiCategory(double bmi) {
|
||||
if (bmi < 18.5) return 'Underweight';
|
||||
if (bmi < 25) return 'Normal weight';
|
||||
if (bmi < 30) return 'Overweight';
|
||||
return 'Obese';
|
||||
}
|
||||
|
||||
static Color getBmiColor(double bmi) {
|
||||
if (bmi < 18.5) return Colors.blue;
|
||||
if (bmi < 25) return Colors.green;
|
||||
if (bmi < 30) return Colors.orange;
|
||||
return Colors.red;
|
||||
}
|
||||
|
||||
// Age calculation
|
||||
static int calculateAge(DateTime birthDate) {
|
||||
final now = DateTime.now();
|
||||
int age = now.year - birthDate.year;
|
||||
if (now.month < birthDate.month ||
|
||||
(now.month == birthDate.month && now.day < birthDate.day)) {
|
||||
age--;
|
||||
}
|
||||
return age;
|
||||
}
|
||||
|
||||
// Format height for display
|
||||
static String formatHeight(double cm, HeightUnit unit) {
|
||||
switch (unit) {
|
||||
case HeightUnit.metric:
|
||||
return '${cm.toStringAsFixed(1)} cm';
|
||||
case HeightUnit.imperial:
|
||||
return cmToFeetInches(cm);
|
||||
}
|
||||
}
|
||||
|
||||
// Format weight for display
|
||||
static String formatWeight(double kg, WeightUnit unit) {
|
||||
switch (unit) {
|
||||
case WeightUnit.metric:
|
||||
return '${kg.toStringAsFixed(1)} kg';
|
||||
case WeightUnit.imperial:
|
||||
final lbs = kgToLbs(kg);
|
||||
return '${lbs.toStringAsFixed(1)} lbs';
|
||||
}
|
||||
}
|
||||
|
||||
// Parse height from input
|
||||
static double? parseHeight(String input, HeightUnit unit) {
|
||||
try {
|
||||
switch (unit) {
|
||||
case HeightUnit.metric:
|
||||
final value = double.tryParse(input.replaceAll(RegExp(r'[^0-9.]'), ''));
|
||||
return value;
|
||||
case HeightUnit.imperial:
|
||||
return feetInchesToCm(input);
|
||||
}
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse weight from input
|
||||
static double? parseWeight(String input, WeightUnit unit) {
|
||||
try {
|
||||
final value = double.tryParse(input.replaceAll(RegExp(r'[^0-9.]'), ''));
|
||||
if (value == null) return null;
|
||||
|
||||
switch (unit) {
|
||||
case WeightUnit.metric:
|
||||
return value;
|
||||
case WeightUnit.imperial:
|
||||
return lbsToKg(value);
|
||||
}
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class BiometricData {
|
||||
final int? age;
|
||||
final Gender? gender;
|
||||
final double? heightCm;
|
||||
final double? weightKg;
|
||||
final HeightUnit heightUnit;
|
||||
final WeightUnit weightUnit;
|
||||
|
||||
const BiometricData({
|
||||
this.age,
|
||||
this.gender,
|
||||
this.heightCm,
|
||||
this.weightKg,
|
||||
this.heightUnit = HeightUnit.metric,
|
||||
this.weightUnit = WeightUnit.metric,
|
||||
});
|
||||
|
||||
double? get bmi {
|
||||
if (heightCm == null || weightKg == null) return null;
|
||||
return UnitConversionUtils.calculateBmi(weightKg!, heightCm!);
|
||||
}
|
||||
|
||||
String get bmiCategory {
|
||||
final bmiValue = bmi;
|
||||
if (bmiValue == null) return '';
|
||||
return UnitConversionUtils.getBmiCategory(bmiValue);
|
||||
}
|
||||
|
||||
String get formattedHeight {
|
||||
if (heightCm == null) return '';
|
||||
return UnitConversionUtils.formatHeight(heightCm!, heightUnit);
|
||||
}
|
||||
|
||||
String get formattedWeight {
|
||||
if (weightKg == null) return '';
|
||||
return UnitConversionUtils.formatWeight(weightKg!, weightUnit);
|
||||
}
|
||||
|
||||
BiometricData copyWith({
|
||||
int? age,
|
||||
Gender? gender,
|
||||
double? heightCm,
|
||||
double? weightKg,
|
||||
HeightUnit? heightUnit,
|
||||
WeightUnit? weightUnit,
|
||||
}) {
|
||||
return BiometricData(
|
||||
age: age ?? this.age,
|
||||
gender: gender ?? this.gender,
|
||||
heightCm: heightCm ?? this.heightCm,
|
||||
weightKg: weightKg ?? this.weightKg,
|
||||
heightUnit: heightUnit ?? this.heightUnit,
|
||||
weightUnit: weightUnit ?? this.weightUnit,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../utils/unit_conversion_utils.dart';
|
||||
|
||||
class UnitInputField extends StatefulWidget {
|
||||
final String labelText;
|
||||
final IconData prefixIcon;
|
||||
final String helperText;
|
||||
final bool enabled;
|
||||
final ValueChanged<double?> onValueChanged;
|
||||
final ValueChanged<dynamic>? onUnitChanged;
|
||||
final double? initialValue;
|
||||
final bool isHeight;
|
||||
|
||||
const UnitInputField({
|
||||
super.key,
|
||||
required this.labelText,
|
||||
required this.prefixIcon,
|
||||
required this.helperText,
|
||||
this.enabled = true,
|
||||
required this.onValueChanged,
|
||||
this.onUnitChanged,
|
||||
this.initialValue,
|
||||
required this.isHeight,
|
||||
});
|
||||
|
||||
@override
|
||||
State<UnitInputField> createState() => _UnitInputFieldState();
|
||||
}
|
||||
|
||||
class _UnitInputFieldState extends State<UnitInputField> {
|
||||
late TextEditingController _controller;
|
||||
late HeightUnit _selectedHeightUnit;
|
||||
late WeightUnit _selectedWeightUnit;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController();
|
||||
_selectedHeightUnit = HeightUnit.metric;
|
||||
_selectedWeightUnit = WeightUnit.metric;
|
||||
|
||||
// Set initial value if provided
|
||||
if (widget.initialValue != null) {
|
||||
if (widget.isHeight) {
|
||||
_controller.text = widget.initialValue!.toStringAsFixed(1);
|
||||
} else {
|
||||
_controller.text = widget.initialValue!.toStringAsFixed(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onUnitChanged(dynamic unit) {
|
||||
setState(() {
|
||||
if (widget.isHeight) {
|
||||
_selectedHeightUnit = unit as HeightUnit;
|
||||
} else {
|
||||
_selectedWeightUnit = unit as WeightUnit;
|
||||
}
|
||||
});
|
||||
// Notify parent widget of unit change
|
||||
widget.onUnitChanged?.call(unit);
|
||||
_convertAndNotify();
|
||||
}
|
||||
|
||||
void _showUnitSelector() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Select Unit'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: (widget.isHeight ? HeightUnit.values : WeightUnit.values).map((unit) {
|
||||
return RadioListTile<dynamic>(
|
||||
title: Text(widget.isHeight ? (unit as HeightUnit).displayName : (unit as WeightUnit).displayName),
|
||||
value: unit,
|
||||
groupValue: widget.isHeight ? _selectedHeightUnit : _selectedWeightUnit,
|
||||
onChanged: widget.enabled ? (value) {
|
||||
_onUnitChanged(value);
|
||||
Navigator.of(context).pop();
|
||||
} : null,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _onTextChanged(String text) {
|
||||
_convertAndNotify();
|
||||
}
|
||||
|
||||
void _convertAndNotify() {
|
||||
final text = _controller.text.trim();
|
||||
if (text.isEmpty) {
|
||||
widget.onValueChanged(null);
|
||||
return;
|
||||
}
|
||||
|
||||
double? valueInCmOrKg;
|
||||
|
||||
if (widget.isHeight) {
|
||||
valueInCmOrKg = UnitConversionUtils.parseHeight(text, _selectedHeightUnit);
|
||||
} else {
|
||||
valueInCmOrKg = UnitConversionUtils.parseWeight(text, _selectedWeightUnit);
|
||||
}
|
||||
|
||||
widget.onValueChanged(valueInCmOrKg);
|
||||
}
|
||||
|
||||
String get _unitDisplayText {
|
||||
if (widget.isHeight) {
|
||||
return _selectedHeightUnit.displayName;
|
||||
} else {
|
||||
return _selectedWeightUnit.displayName;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
// Input field
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: TextFormField(
|
||||
controller: _controller,
|
||||
keyboardType: TextInputType.number,
|
||||
textInputAction: TextInputAction.next,
|
||||
decoration: InputDecoration(
|
||||
labelText: widget.labelText,
|
||||
prefixIcon: Icon(widget.prefixIcon),
|
||||
helperText: widget.helperText,
|
||||
suffixText: _unitDisplayText,
|
||||
isDense: true, // Make the input field more compact
|
||||
),
|
||||
enabled: widget.enabled,
|
||||
onChanged: widget.enabled ? _onTextChanged : null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Unit selector - custom button
|
||||
Container(
|
||||
width: 45,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
onTap: widget.enabled ? () => _showUnitSelector() : null,
|
||||
child: Center(
|
||||
child: Text(
|
||||
_unitDisplayText,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'cached_goal.g.dart';
|
||||
part 'cached_goal_model.g.dart';
|
||||
|
||||
@HiveType(typeId: 0)
|
||||
class CachedGoal extends HiveObject {
|
||||
|
||||
+37
-28
@@ -1,5 +1,11 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'cached_goal_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class CachedGoalAdapter extends TypeAdapter<CachedGoal> {
|
||||
@override
|
||||
final int typeId = 0;
|
||||
@@ -29,33 +35,34 @@ class CachedGoalAdapter extends TypeAdapter<CachedGoal> {
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, CachedGoal obj) {
|
||||
writer.writeByte(13);
|
||||
writer.writeByte(0);
|
||||
writer.write(obj.id);
|
||||
writer.writeByte(1);
|
||||
writer.write(obj.ownerId);
|
||||
writer.writeByte(2);
|
||||
writer.write(obj.title);
|
||||
writer.writeByte(3);
|
||||
writer.write(obj.description);
|
||||
writer.writeByte(4);
|
||||
writer.write(obj.progress);
|
||||
writer.writeByte(5);
|
||||
writer.write(obj.locationLat);
|
||||
writer.writeByte(6);
|
||||
writer.write(obj.locationLng);
|
||||
writer.writeByte(7);
|
||||
writer.write(obj.locationName);
|
||||
writer.writeByte(8);
|
||||
writer.write(obj.imageUrl);
|
||||
writer.writeByte(9);
|
||||
writer.write(obj.completed);
|
||||
writer.writeByte(10);
|
||||
writer.write(obj.createdAt);
|
||||
writer.writeByte(11);
|
||||
writer.write(obj.updatedAt);
|
||||
writer.writeByte(12);
|
||||
writer.write(obj.isDirty);
|
||||
writer
|
||||
..writeByte(13)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
..write(obj.ownerId)
|
||||
..writeByte(2)
|
||||
..write(obj.title)
|
||||
..writeByte(3)
|
||||
..write(obj.description)
|
||||
..writeByte(4)
|
||||
..write(obj.progress)
|
||||
..writeByte(5)
|
||||
..write(obj.locationLat)
|
||||
..writeByte(6)
|
||||
..write(obj.locationLng)
|
||||
..writeByte(7)
|
||||
..write(obj.locationName)
|
||||
..writeByte(8)
|
||||
..write(obj.imageUrl)
|
||||
..writeByte(9)
|
||||
..write(obj.completed)
|
||||
..writeByte(10)
|
||||
..write(obj.createdAt)
|
||||
..writeByte(11)
|
||||
..write(obj.updatedAt)
|
||||
..writeByte(12)
|
||||
..write(obj.isDirty);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -64,5 +71,7 @@ class CachedGoalAdapter extends TypeAdapter<CachedGoal> {
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is CachedGoalAdapter && runtimeType == other.runtimeType && typeId == other.typeId;
|
||||
other is CachedGoalAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../core/utils/unit_conversion_utils.dart';
|
||||
|
||||
class User extends Equatable {
|
||||
final String id;
|
||||
@@ -13,6 +14,13 @@ class User extends Equatable {
|
||||
final String? websiteUrl;
|
||||
final DateTime? countdownStartDate;
|
||||
final DateTime? countdownEndDate;
|
||||
final Gender? gender;
|
||||
final DateTime? birthDate;
|
||||
final int? storedAge;
|
||||
final double? heightCm;
|
||||
final double? weightKg;
|
||||
final HeightUnit heightUnit;
|
||||
final WeightUnit weightUnit;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
@@ -29,6 +37,13 @@ class User extends Equatable {
|
||||
this.websiteUrl,
|
||||
this.countdownStartDate,
|
||||
this.countdownEndDate,
|
||||
this.gender,
|
||||
this.birthDate,
|
||||
this.storedAge,
|
||||
this.heightCm,
|
||||
this.weightKg,
|
||||
this.heightUnit = HeightUnit.metric,
|
||||
this.weightUnit = WeightUnit.metric,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
@@ -45,6 +60,33 @@ class User extends Equatable {
|
||||
return countdownEndDate!.difference(DateTime.now()).inDays;
|
||||
}
|
||||
|
||||
int? get age {
|
||||
if (storedAge != null) return storedAge;
|
||||
if (birthDate == null) return null;
|
||||
return UnitConversionUtils.calculateAge(birthDate!);
|
||||
}
|
||||
|
||||
String get formattedHeight {
|
||||
if (heightCm == null) return '';
|
||||
return UnitConversionUtils.formatHeight(heightCm!, heightUnit);
|
||||
}
|
||||
|
||||
String get formattedWeight {
|
||||
if (weightKg == null) return '';
|
||||
return UnitConversionUtils.formatWeight(weightKg!, weightUnit);
|
||||
}
|
||||
|
||||
double? get bmi {
|
||||
if (heightCm == null || weightKg == null) return null;
|
||||
return UnitConversionUtils.calculateBmi(weightKg!, heightCm!);
|
||||
}
|
||||
|
||||
String get bmiCategory {
|
||||
final bmiValue = bmi;
|
||||
if (bmiValue == null) return '';
|
||||
return UnitConversionUtils.getBmiCategory(bmiValue);
|
||||
}
|
||||
|
||||
User copyWith({
|
||||
String? id,
|
||||
String? username,
|
||||
@@ -58,6 +100,13 @@ class User extends Equatable {
|
||||
String? websiteUrl,
|
||||
DateTime? countdownStartDate,
|
||||
DateTime? countdownEndDate,
|
||||
Gender? gender,
|
||||
DateTime? birthDate,
|
||||
int? storedAge,
|
||||
double? heightCm,
|
||||
double? weightKg,
|
||||
HeightUnit? heightUnit,
|
||||
WeightUnit? weightUnit,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
@@ -74,6 +123,13 @@ class User extends Equatable {
|
||||
websiteUrl: websiteUrl ?? this.websiteUrl,
|
||||
countdownStartDate: countdownStartDate ?? this.countdownStartDate,
|
||||
countdownEndDate: countdownEndDate ?? this.countdownEndDate,
|
||||
gender: gender ?? this.gender,
|
||||
birthDate: birthDate ?? this.birthDate,
|
||||
storedAge: storedAge ?? this.storedAge,
|
||||
heightCm: heightCm ?? this.heightCm,
|
||||
weightKg: weightKg ?? this.weightKg,
|
||||
heightUnit: heightUnit ?? this.heightUnit,
|
||||
weightUnit: weightUnit ?? this.weightUnit,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
@@ -93,6 +149,13 @@ class User extends Equatable {
|
||||
websiteUrl,
|
||||
countdownStartDate,
|
||||
countdownEndDate,
|
||||
gender,
|
||||
birthDate,
|
||||
storedAge,
|
||||
heightCm,
|
||||
weightKg,
|
||||
heightUnit,
|
||||
weightUnit,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
];
|
||||
@@ -111,6 +174,13 @@ class User extends Equatable {
|
||||
'website_url': websiteUrl,
|
||||
'countdown_start_date': countdownStartDate?.toIso8601String(),
|
||||
'countdown_end_date': countdownEndDate?.toIso8601String(),
|
||||
'gender': gender?.toDatabaseString(),
|
||||
'birth_date': birthDate?.toIso8601String().split('T').first,
|
||||
'age': storedAge,
|
||||
'height_cm': heightCm,
|
||||
'weight_kg': weightKg,
|
||||
'height_unit': heightUnit.code,
|
||||
'weight_unit': weightUnit.code,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'updated_at': updatedAt.toIso8601String(),
|
||||
};
|
||||
@@ -134,6 +204,15 @@ class User extends Equatable {
|
||||
countdownEndDate: json['countdown_end_date'] != null
|
||||
? DateTime.parse(json['countdown_end_date'] as String)
|
||||
: null,
|
||||
gender: json['gender'] != null ? Gender.fromString(json['gender'] as String) : null,
|
||||
birthDate: json['birth_date'] != null ? DateTime.parse(json['birth_date'] as String) : null,
|
||||
storedAge: json['age'] as int?,
|
||||
heightCm: json['height_cm'] as double?,
|
||||
weightKg: json['weight_kg'] as double?,
|
||||
heightUnit: json['height_unit'] != null ?
|
||||
HeightUnit.values.firstWhere((unit) => unit.code == json['height_unit']) : HeightUnit.metric,
|
||||
weightUnit: json['weight_unit'] != null ?
|
||||
WeightUnit.values.firstWhere((unit) => unit.code == json['weight_unit']) : WeightUnit.metric,
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
);
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import 'dart:async';
|
||||
import '../models/user_model.dart';
|
||||
import '../../bootstrap/supabase_client.dart';
|
||||
import '../../core/utils/unit_conversion_utils.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
|
||||
import 'package:google_sign_in/google_sign_in.dart';
|
||||
|
||||
class AuthRepository {
|
||||
final supabase.SupabaseClient _client;
|
||||
final supabase.SupabaseClient? _client;
|
||||
StreamSubscription<supabase.AuthState>? _authStateSubscription;
|
||||
|
||||
AuthRepository([supabase.SupabaseClient? client]) : _client = client ?? supabaseClient;
|
||||
AuthRepository([supabase.SupabaseClient? client]) : _client = client;
|
||||
|
||||
Stream<User?> get authStateChanges {
|
||||
return _client.auth.onAuthStateChange.map((data) {
|
||||
final client = supabaseClient;
|
||||
if (client == null) {
|
||||
// Return a stream that never emits if Supabase is not initialized
|
||||
return Stream.empty();
|
||||
}
|
||||
return client.auth.onAuthStateChange.map((data) {
|
||||
final session = data.session;
|
||||
if (session?.user != null) {
|
||||
return _mapSupabaseUserToAppUser(session!.user);
|
||||
@@ -21,16 +27,27 @@ class AuthRepository {
|
||||
}
|
||||
|
||||
User? get currentUser {
|
||||
final user = _client.auth.currentUser;
|
||||
final client = supabaseClient;
|
||||
if (client == null) return null;
|
||||
final user = client.auth.currentUser;
|
||||
return user != null ? _mapSupabaseUserToAppUser(user) : null;
|
||||
}
|
||||
|
||||
bool get isAuthenticated => _client.auth.currentUser != null;
|
||||
bool get isAuthenticated {
|
||||
final client = supabaseClient;
|
||||
if (client == null) return false;
|
||||
return client.auth.currentUser != null;
|
||||
}
|
||||
|
||||
String? get currentUserId => _client.auth.currentUser?.id;
|
||||
String? get currentUserId {
|
||||
final client = supabaseClient;
|
||||
if (client == null) return null;
|
||||
return client.auth.currentUser?.id;
|
||||
}
|
||||
|
||||
Future<bool> isSessionValid() async {
|
||||
final session = _client.auth.currentSession;
|
||||
assert(_client != null, 'Client must not be null');
|
||||
final session = _client!.auth.currentSession;
|
||||
if (session == null) return false;
|
||||
|
||||
final now = DateTime.now();
|
||||
@@ -41,19 +58,22 @@ class AuthRepository {
|
||||
}
|
||||
|
||||
Future<void> refreshSession() async {
|
||||
assert(_client != null, 'Client must not be null');
|
||||
try {
|
||||
await _client.auth.refreshSession();
|
||||
await _client!.auth.refreshSession();
|
||||
} catch (e) {
|
||||
throw Exception('Failed to refresh session: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<supabase.Session?> getCurrentSession() async {
|
||||
return _client.auth.currentSession;
|
||||
assert(_client != null, 'Client must not be null');
|
||||
return _client!.auth.currentSession;
|
||||
}
|
||||
|
||||
void listenToAuthStateChanges(Function(User?) callback) {
|
||||
_authStateSubscription = _client.auth.onAuthStateChange.listen((data) {
|
||||
assert(_client != null, 'Client must not be null');
|
||||
_authStateSubscription = _client!.auth.onAuthStateChange.listen((data) {
|
||||
final session = data.session;
|
||||
if (session?.user != null) {
|
||||
callback(_mapSupabaseUserToAppUser(session!.user));
|
||||
@@ -68,58 +88,89 @@ class AuthRepository {
|
||||
}
|
||||
|
||||
Future<void> signInWithEmail(String email, String password) async {
|
||||
await _client.auth.signInWithPassword(email: email, password: password);
|
||||
assert(_client != null, 'Client must not be null');
|
||||
await _client!.auth.signInWithPassword(email: email, password: password);
|
||||
}
|
||||
|
||||
Future<void> signUpWithEmail(String email, String password, String username) async {
|
||||
final response = await _client.auth.signUp(
|
||||
Future<void> signUpWithEmail(String email, String password, String username, {double? heightCm, double? weightKg, int? age, Gender? gender, HeightUnit? heightUnit, WeightUnit? weightUnit}) async {
|
||||
assert(_client != null, 'Client must not be null');
|
||||
final response = await _client!.auth.signUp(
|
||||
email: email,
|
||||
password: password,
|
||||
data: {'username': username},
|
||||
);
|
||||
|
||||
if (response.user != null) {
|
||||
await _createUserProfile(response.user!.id, username, email);
|
||||
await _createUserProfile(response.user!.id, username, email, heightCm: heightCm, weightKg: weightKg, age: age, gender: gender, heightUnit: heightUnit, weightUnit: weightUnit);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> signInWithGoogle() async {
|
||||
final GoogleSignIn googleSignIn = GoogleSignIn();
|
||||
assert(_client != null, 'Client must not be null');
|
||||
try {
|
||||
final GoogleSignIn googleSignIn = GoogleSignIn(
|
||||
scopes: ['email', 'profile'],
|
||||
);
|
||||
|
||||
final googleUser = await googleSignIn.signIn();
|
||||
if (googleUser == null) {
|
||||
// Check if user is already signed in
|
||||
final googleUser = await googleSignIn.signInSilently();
|
||||
if (googleUser != null) {
|
||||
await _handleGoogleUser(googleUser);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sign in interactively
|
||||
final interactiveUser = await googleSignIn.signIn();
|
||||
if (interactiveUser == null) {
|
||||
throw Exception('Google sign-in was cancelled');
|
||||
}
|
||||
|
||||
final googleAuth = await googleUser.authentication;
|
||||
final idToken = googleAuth.idToken;
|
||||
|
||||
if (idToken == null) {
|
||||
throw Exception('No ID token from Google sign-in');
|
||||
await _handleGoogleUser(interactiveUser);
|
||||
} catch (e) {
|
||||
throw Exception('Google sign-in failed: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
final response = await _client.auth.signInWithIdToken(
|
||||
Future<void> _handleGoogleUser(dynamic googleUser) async {
|
||||
assert(_client != null, 'Client must not be null');
|
||||
try {
|
||||
final googleAuth = await googleUser.authentication;
|
||||
final idToken = googleAuth.idToken;
|
||||
final accessToken = googleAuth.accessToken;
|
||||
|
||||
if (idToken == null && accessToken == null) {
|
||||
throw Exception('No ID token or access token from Google sign-in');
|
||||
}
|
||||
|
||||
final response = await _client!.auth.signInWithIdToken(
|
||||
provider: supabase.OAuthProvider.google,
|
||||
idToken: idToken,
|
||||
accessToken: accessToken,
|
||||
);
|
||||
|
||||
if (response.user != null) {
|
||||
await _ensureUserProfileExists(response.user!.id, response.user!);
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Failed to authenticate with Google: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> signInWithGithub() async {
|
||||
await _client.auth.signInWithOAuth(
|
||||
assert(_client != null, 'Client must not be null');
|
||||
await _client!.auth.signInWithOAuth(
|
||||
supabase.OAuthProvider.github,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> signOut() async {
|
||||
await _client.auth.signOut();
|
||||
assert(_client != null, 'Client must not be null');
|
||||
await _client!.auth.signOut();
|
||||
}
|
||||
|
||||
Future<void> resetPassword(String email) async {
|
||||
await _client.auth.resetPasswordForEmail(email);
|
||||
assert(_client != null, 'Client must not be null');
|
||||
await _client!.auth.resetPasswordForEmail(email);
|
||||
}
|
||||
|
||||
Future<void> updateProfile({
|
||||
@@ -127,8 +178,15 @@ class AuthRepository {
|
||||
String? bio,
|
||||
String? avatarUrl,
|
||||
bool? isPublicProfile,
|
||||
double? heightCm,
|
||||
double? weightKg,
|
||||
int? age,
|
||||
Gender? gender,
|
||||
HeightUnit? heightUnit,
|
||||
WeightUnit? weightUnit,
|
||||
}) async {
|
||||
final userId = _client.auth.currentUser?.id;
|
||||
assert(_client != null, 'Client must not be null');
|
||||
final userId = _client!.auth.currentUser?.id;
|
||||
if (userId == null) throw Exception('User not authenticated');
|
||||
|
||||
final updates = <String, dynamic>{};
|
||||
@@ -136,30 +194,103 @@ class AuthRepository {
|
||||
if (bio != null) updates['bio'] = bio;
|
||||
if (avatarUrl != null) updates['avatar_url'] = avatarUrl;
|
||||
if (isPublicProfile != null) updates['is_public_profile'] = isPublicProfile;
|
||||
if (heightCm != null) updates['height_cm'] = heightCm;
|
||||
if (weightKg != null) updates['weight_kg'] = weightKg;
|
||||
if (age != null) updates['age'] = age;
|
||||
if (gender != null) updates['gender'] = gender.toDatabaseString();
|
||||
if (heightUnit != null) updates['height_unit'] = heightUnit.code;
|
||||
if (weightUnit != null) updates['weight_unit'] = weightUnit.code;
|
||||
updates['updated_at'] = DateTime.now().toIso8601String();
|
||||
|
||||
await _client
|
||||
await _client!
|
||||
.from('users')
|
||||
.update(updates)
|
||||
.eq('id', userId);
|
||||
}
|
||||
|
||||
Future<User> _createUserProfile(String userId, String username, String email) async {
|
||||
Future<User> _createUserProfile(String userId, String username, String email, {double? heightCm, double? weightKg, int? age, Gender? gender, HeightUnit? heightUnit, WeightUnit? weightUnit}) async {
|
||||
assert(_client != null, 'Client must not be null');
|
||||
final now = DateTime.now().toIso8601String();
|
||||
|
||||
final response = await _client.from('users').insert({
|
||||
try {
|
||||
// First try with the regular client (might fail due to RLS)
|
||||
final response = await _client!.from('users').insert({
|
||||
'id': userId,
|
||||
'username': username,
|
||||
'email': email,
|
||||
'height_cm': heightCm,
|
||||
'weight_kg': weightKg,
|
||||
'age': age,
|
||||
'gender': gender?.toDatabaseString(),
|
||||
'height_unit': heightUnit?.code ?? HeightUnit.metric.code,
|
||||
'weight_unit': weightUnit?.code ?? WeightUnit.metric.code,
|
||||
'created_at': now,
|
||||
'updated_at': now,
|
||||
}).select().single();
|
||||
}).select();
|
||||
|
||||
return _mapSupabaseDataToUser(response);
|
||||
if (response.isNotEmpty) {
|
||||
return _mapSupabaseDataToUser(response.first);
|
||||
}
|
||||
} catch (e) {
|
||||
// If regular client fails due to RLS, try with service role client
|
||||
try {
|
||||
final serviceClient = getServiceRoleClient();
|
||||
final response = await serviceClient.from('users').insert({
|
||||
'id': userId,
|
||||
'username': username,
|
||||
'email': email,
|
||||
'height_cm': heightCm,
|
||||
'weight_kg': weightKg,
|
||||
'age': age,
|
||||
'gender': gender?.toDatabaseString(),
|
||||
'height_unit': heightUnit?.code ?? HeightUnit.metric.code,
|
||||
'weight_unit': weightUnit?.code ?? WeightUnit.metric.code,
|
||||
'created_at': now,
|
||||
'updated_at': now,
|
||||
}).select();
|
||||
|
||||
if (response.isNotEmpty) {
|
||||
return _mapSupabaseDataToUser(response.first);
|
||||
}
|
||||
} catch (e2) {
|
||||
// If both fail, create a basic user profile from auth metadata
|
||||
// This allows the app to function even without database profile creation
|
||||
return User(
|
||||
id: userId,
|
||||
username: username,
|
||||
email: email,
|
||||
storedAge: age,
|
||||
heightCm: heightCm,
|
||||
weightKg: weightKg,
|
||||
gender: gender,
|
||||
heightUnit: heightUnit ?? HeightUnit.metric,
|
||||
weightUnit: weightUnit ?? WeightUnit.metric,
|
||||
createdAt: DateTime.parse(now),
|
||||
updatedAt: DateTime.parse(now),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback if no response but no error
|
||||
return User(
|
||||
id: userId,
|
||||
username: username,
|
||||
email: email,
|
||||
storedAge: age,
|
||||
heightCm: heightCm,
|
||||
weightKg: weightKg,
|
||||
gender: gender,
|
||||
heightUnit: heightUnit ?? HeightUnit.metric,
|
||||
weightUnit: weightUnit ?? WeightUnit.metric,
|
||||
createdAt: DateTime.parse(now),
|
||||
updatedAt: DateTime.parse(now),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _ensureUserProfileExists(String userId, dynamic supabaseUser) async {
|
||||
final existingProfile = await _client
|
||||
assert(_client != null, 'Client must not be null');
|
||||
try {
|
||||
final existingProfile = await _client!
|
||||
.from('users')
|
||||
.select('id')
|
||||
.eq('id', userId)
|
||||
@@ -171,6 +302,14 @@ class AuthRepository {
|
||||
final email = supabaseUser.email ?? '';
|
||||
await _createUserProfile(userId, username, email);
|
||||
}
|
||||
} catch (e) {
|
||||
// If RLS policy prevents reading, we'll assume the profile doesn't exist
|
||||
// and let the _createUserProfile method handle the creation gracefully
|
||||
final username = supabaseUser.userMetadata?['username'] ??
|
||||
'user_${userId.substring(0, 8)}';
|
||||
final email = supabaseUser.email ?? '';
|
||||
await _createUserProfile(userId, username, email);
|
||||
}
|
||||
}
|
||||
|
||||
User _mapSupabaseUserToAppUser(dynamic supabaseUser) {
|
||||
@@ -197,6 +336,10 @@ class AuthRepository {
|
||||
countdownEndDate: data['countdown_end_date'] != null
|
||||
? DateTime.parse(data['countdown_end_date'])
|
||||
: null,
|
||||
gender: data['gender'] != null ? Gender.fromString(data['gender']) : null,
|
||||
storedAge: data['age'] as int?,
|
||||
heightCm: (data['height_cm'] as num?)?.toDouble(),
|
||||
weightKg: (data['weight_kg'] as num?)?.toDouble(),
|
||||
createdAt: DateTime.parse(data['created_at']),
|
||||
updatedAt: DateTime.parse(data['updated_at']),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
|
||||
import '../models/user_model.dart' as app;
|
||||
import '../../core/errors/failure.dart';
|
||||
import '../../core/utils/unit_conversion_utils.dart';
|
||||
|
||||
class UserRepository {
|
||||
final supabase.SupabaseClient _client;
|
||||
@@ -13,9 +14,13 @@ class UserRepository {
|
||||
.from('users')
|
||||
.select()
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
.maybeSingle();
|
||||
|
||||
if (response != null) {
|
||||
return app.User.fromJson(response);
|
||||
} else {
|
||||
throw const ServerFailure('User profile not found');
|
||||
}
|
||||
} catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
@@ -31,6 +36,12 @@ class UserRepository {
|
||||
String? instagramHandle,
|
||||
String? tiktokHandle,
|
||||
String? websiteUrl,
|
||||
Gender? gender,
|
||||
DateTime? birthDate,
|
||||
double? heightCm,
|
||||
double? weightKg,
|
||||
HeightUnit heightUnit = HeightUnit.metric,
|
||||
WeightUnit weightUnit = WeightUnit.metric,
|
||||
}) async {
|
||||
try {
|
||||
final updates = <String, dynamic>{};
|
||||
@@ -42,16 +53,25 @@ class UserRepository {
|
||||
if (instagramHandle != null) updates['instagram_handle'] = instagramHandle;
|
||||
if (tiktokHandle != null) updates['tiktok_handle'] = tiktokHandle;
|
||||
if (websiteUrl != null) updates['website_url'] = websiteUrl;
|
||||
if (gender != null) updates['gender'] = gender.toDatabaseString();
|
||||
if (birthDate != null) updates['birth_date'] = birthDate.toIso8601String().split('T').first;
|
||||
if (heightCm != null) updates['height_cm'] = heightCm;
|
||||
if (weightKg != null) updates['weight_kg'] = weightKg;
|
||||
updates['height_unit'] = heightUnit.code;
|
||||
updates['weight_unit'] = weightUnit.code;
|
||||
updates['updated_at'] = DateTime.now().toIso8601String();
|
||||
|
||||
final response = await _client
|
||||
.from('users')
|
||||
.update(updates)
|
||||
.eq('id', userId)
|
||||
.select()
|
||||
.single();
|
||||
.select();
|
||||
|
||||
return app.User.fromJson(response);
|
||||
if (response.isNotEmpty) {
|
||||
return app.User.fromJson(response.first);
|
||||
} else {
|
||||
throw const ServerFailure('Failed to update profile');
|
||||
}
|
||||
} catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:local_auth/local_auth.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
enum BiometricAvailability {
|
||||
available,
|
||||
notAvailable,
|
||||
notEnrolled,
|
||||
lockedOut,
|
||||
permanentlyUnavailable;
|
||||
|
||||
String get message {
|
||||
switch (this) {
|
||||
case BiometricAvailability.available:
|
||||
return 'Biometric authentication is available';
|
||||
case BiometricAvailability.notAvailable:
|
||||
return 'Biometric authentication is not available on this device';
|
||||
case BiometricAvailability.notEnrolled:
|
||||
return 'No biometric credentials enrolled on this device';
|
||||
case BiometricAvailability.lockedOut:
|
||||
return 'Biometric authentication is temporarily locked out';
|
||||
case BiometricAvailability.permanentlyUnavailable:
|
||||
return 'Biometric authentication is permanently unavailable';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class BiometricService {
|
||||
static const String _biometricEnabledKey = 'biometric_enabled';
|
||||
static const String _biometricUserIdKey = 'biometric_user_id';
|
||||
|
||||
final LocalAuthentication _localAuth = LocalAuthentication();
|
||||
|
||||
/// Get display name for biometric type
|
||||
String getBiometricDisplayName(BiometricType type) {
|
||||
switch (type) {
|
||||
case BiometricType.fingerprint:
|
||||
return 'Fingerprint';
|
||||
case BiometricType.face:
|
||||
return 'Face ID';
|
||||
case BiometricType.iris:
|
||||
return 'Iris Scanner';
|
||||
default:
|
||||
return 'Biometric';
|
||||
}
|
||||
}
|
||||
|
||||
/// Get emoji for biometric type
|
||||
String getBiometricEmoji(BiometricType type) {
|
||||
switch (type) {
|
||||
case BiometricType.fingerprint:
|
||||
return '👆';
|
||||
case BiometricType.face:
|
||||
return '👤';
|
||||
case BiometricType.iris:
|
||||
return '👁️';
|
||||
default:
|
||||
return '🔒';
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if biometric authentication is available
|
||||
Future<BiometricAvailability> checkAvailability() async {
|
||||
try {
|
||||
// Check if device supports biometric authentication
|
||||
final canCheckBiometrics = await _localAuth.canCheckBiometrics;
|
||||
if (!canCheckBiometrics) {
|
||||
return BiometricAvailability.notAvailable;
|
||||
}
|
||||
|
||||
// Check if biometric credentials are enrolled
|
||||
final isDeviceSupported = await _localAuth.isDeviceSupported();
|
||||
if (!isDeviceSupported) {
|
||||
return BiometricAvailability.notAvailable;
|
||||
}
|
||||
|
||||
// Try to get available biometric types
|
||||
final availableBiometrics = await _localAuth.getAvailableBiometrics();
|
||||
if (availableBiometrics.isEmpty) {
|
||||
return BiometricAvailability.notEnrolled;
|
||||
}
|
||||
|
||||
return BiometricAvailability.available;
|
||||
} on PlatformException catch (e) {
|
||||
if (e.code == 'LockedOut') {
|
||||
return BiometricAvailability.lockedOut;
|
||||
} else if (e.code == 'PermanentlyEnrolled') {
|
||||
return BiometricAvailability.permanentlyUnavailable;
|
||||
} else if (e.code == 'NotAvailable') {
|
||||
return BiometricAvailability.notAvailable;
|
||||
} else if (e.code == 'NotEnrolled') {
|
||||
return BiometricAvailability.notEnrolled;
|
||||
}
|
||||
return BiometricAvailability.notAvailable;
|
||||
} catch (e) {
|
||||
return BiometricAvailability.notAvailable;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get available biometric types
|
||||
Future<List<BiometricType>> getAvailableBiometrics() async {
|
||||
try {
|
||||
final availableBiometrics = await _localAuth.getAvailableBiometrics();
|
||||
return availableBiometrics.toList();
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if biometric login is enabled for a user
|
||||
Future<bool> isBiometricEnabled() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_biometricEnabledKey) ?? false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable biometric login for a user
|
||||
Future<bool> enableBiometric(String userId) async {
|
||||
try {
|
||||
// First verify biometric is available
|
||||
final availability = await checkAvailability();
|
||||
if (availability != BiometricAvailability.available) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Test biometric authentication
|
||||
final authenticated = await authenticate(
|
||||
reason: 'Enable biometric login for faster access',
|
||||
localizedReason: 'Enable biometric login for faster access to your 1356 day challenge',
|
||||
);
|
||||
|
||||
if (authenticated) {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_biometricEnabledKey, true);
|
||||
await prefs.setString(_biometricUserIdKey, userId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Disable biometric login
|
||||
Future<bool> disableBiometric() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_biometricEnabledKey);
|
||||
await prefs.remove(_biometricUserIdKey);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the user ID associated with biometric login
|
||||
Future<String?> getBiometricUserId() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_biometricUserIdKey);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Authenticate with biometrics
|
||||
Future<bool> authenticate({
|
||||
String reason = 'Authenticate to access your account',
|
||||
String? localizedReason,
|
||||
bool useErrorDialogs = true,
|
||||
bool stickyAuth = false,
|
||||
bool biometricOnly = true,
|
||||
}) async {
|
||||
try {
|
||||
final authenticated = await _localAuth.authenticate(
|
||||
localizedReason: localizedReason ?? reason,
|
||||
options: AuthenticationOptions(
|
||||
useErrorDialogs: useErrorDialogs,
|
||||
stickyAuth: stickyAuth,
|
||||
biometricOnly: biometricOnly,
|
||||
),
|
||||
);
|
||||
return authenticated;
|
||||
} on PlatformException catch (e) {
|
||||
// Handle common biometric errors
|
||||
if (e.code == 'LockedOut') {
|
||||
// User tried too many times
|
||||
return false;
|
||||
} else if (e.code == 'NotAvailable') {
|
||||
// Biometric not available
|
||||
return false;
|
||||
} else if (e.code == 'NotEnrolled') {
|
||||
// No biometric enrolled
|
||||
return false;
|
||||
} else if (e.code == 'OtherOperatingSystem') {
|
||||
// Not supported on this platform
|
||||
return false;
|
||||
} else if (e.code == 'UserFallback') {
|
||||
// User chose to use password instead
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the primary biometric type for display
|
||||
Future<BiometricType?> getPrimaryBiometricType() async {
|
||||
try {
|
||||
final availableBiometrics = await getAvailableBiometrics();
|
||||
if (availableBiometrics.contains(BiometricType.face)) {
|
||||
return BiometricType.face;
|
||||
} else if (availableBiometrics.contains(BiometricType.fingerprint)) {
|
||||
return BiometricType.fingerprint;
|
||||
} else if (availableBiometrics.contains(BiometricType.iris)) {
|
||||
return BiometricType.iris;
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get user-friendly biometric status message
|
||||
Future<String> getBiometricStatusMessage() async {
|
||||
final availability = await checkAvailability();
|
||||
final isEnabled = await isBiometricEnabled();
|
||||
|
||||
switch (availability) {
|
||||
case BiometricAvailability.available:
|
||||
if (isEnabled) {
|
||||
final type = await getPrimaryBiometricType();
|
||||
if (type != null) {
|
||||
return '${getBiometricDisplayName(type)} is enabled for quick login';
|
||||
}
|
||||
return 'Biometric authentication is enabled for quick login';
|
||||
} else {
|
||||
final type = await getPrimaryBiometricType();
|
||||
if (type != null) {
|
||||
return '${getBiometricDisplayName(type)} is available but not enabled';
|
||||
}
|
||||
return 'Biometric authentication is available but not enabled';
|
||||
}
|
||||
case BiometricAvailability.notAvailable:
|
||||
return 'Biometric authentication is not available on this device';
|
||||
case BiometricAvailability.notEnrolled:
|
||||
return 'No biometric credentials enrolled on this device';
|
||||
case BiometricAvailability.lockedOut:
|
||||
return 'Biometric authentication is temporarily locked. Try again later.';
|
||||
case BiometricAvailability.permanentlyUnavailable:
|
||||
return 'Biometric authentication is permanently unavailable';
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this is a mobile platform that supports biometrics
|
||||
bool get isMobilePlatform {
|
||||
return !kIsWeb && (Platform.isIOS || Platform.isAndroid);
|
||||
}
|
||||
|
||||
/// Get platform-specific biometric name
|
||||
String getPlatformBiometricName() {
|
||||
if (Platform.isIOS) {
|
||||
return 'Face ID / Touch ID';
|
||||
} else if (Platform.isAndroid) {
|
||||
return 'Fingerprint / Face Unlock';
|
||||
}
|
||||
return 'Biometric Authentication';
|
||||
}
|
||||
}
|
||||
@@ -117,10 +117,23 @@ If user context is provided, use it to personalise your responses while respecti
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final choices = data['choices'] as List;
|
||||
final firstChoice = choices.first as Map<String, dynamic>;
|
||||
final message = firstChoice['message'] as Map<String, dynamic>;
|
||||
return message['content'] as String;
|
||||
final choices = data['choices'] as List?;
|
||||
if (choices == null || choices.isEmpty) {
|
||||
throw MistralAIException('No choices returned in response');
|
||||
}
|
||||
final firstChoice = choices.first as Map<String, dynamic>?;
|
||||
if (firstChoice == null) {
|
||||
throw MistralAIException('Invalid choice format in response');
|
||||
}
|
||||
final message = firstChoice['message'] as Map<String, dynamic>?;
|
||||
if (message == null) {
|
||||
throw MistralAIException('No message in choice');
|
||||
}
|
||||
final content = message['content'] as String?;
|
||||
if (content == null) {
|
||||
throw MistralAIException('No content in message');
|
||||
}
|
||||
return content;
|
||||
} else {
|
||||
throw MistralAIException(
|
||||
'Failed to get chat response',
|
||||
|
||||
@@ -6,11 +6,12 @@ class OfflineCacheService {
|
||||
static const String _userBoxName = 'cached_user';
|
||||
static const String _countdownBoxName = 'cached_countdown';
|
||||
|
||||
late Box<CachedGoal> _goalsBox;
|
||||
late Box _userBox;
|
||||
late Box _countdownBox;
|
||||
Box<CachedGoal>? _goalsBox;
|
||||
Box? _userBox;
|
||||
Box? _countdownBox;
|
||||
|
||||
Future<void> init() async {
|
||||
try {
|
||||
await Hive.initFlutter();
|
||||
|
||||
if (!Hive.isAdapterRegistered(0)) {
|
||||
@@ -20,74 +21,101 @@ class OfflineCacheService {
|
||||
_goalsBox = await Hive.openBox<CachedGoal>(_goalsBoxName);
|
||||
_userBox = await Hive.openBox(_userBoxName);
|
||||
_countdownBox = await Hive.openBox(_countdownBoxName);
|
||||
} catch (e) {
|
||||
print('Error initializing offline cache: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cacheGoals(List<CachedGoal> goals) async {
|
||||
await _goalsBox.clear();
|
||||
if (_goalsBox == null) return;
|
||||
|
||||
await _goalsBox!.clear();
|
||||
for (var goal in goals) {
|
||||
await _goalsBox.put(goal.id, goal);
|
||||
await _goalsBox!.put(goal.id, goal);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<CachedGoal>> getCachedGoals() async {
|
||||
return _goalsBox.values.toList();
|
||||
if (_goalsBox == null) return [];
|
||||
|
||||
return _goalsBox!.values.toList();
|
||||
}
|
||||
|
||||
Future<CachedGoal?> getCachedGoal(String goalId) async {
|
||||
return _goalsBox.get(goalId);
|
||||
if (_goalsBox == null) return null;
|
||||
|
||||
return _goalsBox!.get(goalId);
|
||||
}
|
||||
|
||||
Future<void> cacheGoal(CachedGoal goal) async {
|
||||
await _goalsBox.put(goal.id, goal);
|
||||
if (_goalsBox == null) return;
|
||||
|
||||
await _goalsBox!.put(goal.id, goal);
|
||||
}
|
||||
|
||||
Future<void> deleteCachedGoal(String goalId) async {
|
||||
await _goalsBox.delete(goalId);
|
||||
if (_goalsBox == null) return;
|
||||
|
||||
await _goalsBox!.delete(goalId);
|
||||
}
|
||||
|
||||
Future<void> markGoalAsDirty(String goalId) async {
|
||||
final goal = _goalsBox.get(goalId);
|
||||
if (_goalsBox == null) return;
|
||||
|
||||
final goal = _goalsBox!.get(goalId);
|
||||
if (goal != null) {
|
||||
await _goalsBox.put(goalId, goal.copyWith(isDirty: true));
|
||||
await _goalsBox!.put(goalId, goal.copyWith(isDirty: true));
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<CachedGoal>> getDirtyGoals() async {
|
||||
return _goalsBox.values.where((goal) => goal.isDirty).toList();
|
||||
if (_goalsBox == null) return [];
|
||||
|
||||
return _goalsBox!.values.where((goal) => goal.isDirty).toList();
|
||||
}
|
||||
|
||||
Future<void> clearDirtyFlag(String goalId) async {
|
||||
final goal = _goalsBox.get(goalId);
|
||||
if (_goalsBox == null) return;
|
||||
|
||||
final goal = _goalsBox!.get(goalId);
|
||||
if (goal != null) {
|
||||
await _goalsBox.put(goalId, goal.copyWith(isDirty: false));
|
||||
await _goalsBox!.put(goalId, goal.copyWith(isDirty: false));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cacheUserData(Map<String, dynamic> userData) async {
|
||||
await _userBox.putAll(userData);
|
||||
if (_userBox == null) return;
|
||||
|
||||
await _userBox!.putAll(userData);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getCachedUserData() async {
|
||||
return Map<String, dynamic>.from(_userBox.toMap());
|
||||
if (_userBox == null) return {};
|
||||
|
||||
return Map<String, dynamic>.from(_userBox!.toMap());
|
||||
}
|
||||
|
||||
Future<void> cacheCountdownData(Map<String, dynamic> countdownData) async {
|
||||
await _countdownBox.putAll(countdownData);
|
||||
if (_countdownBox == null) return;
|
||||
|
||||
await _countdownBox!.putAll(countdownData);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getCachedCountdownData() async {
|
||||
return Map<String, dynamic>.from(_countdownBox.toMap());
|
||||
if (_countdownBox == null) return {};
|
||||
|
||||
return Map<String, dynamic>.from(_countdownBox!.toMap());
|
||||
}
|
||||
|
||||
Future<void> clearAllCache() async {
|
||||
await _goalsBox.clear();
|
||||
await _userBox.clear();
|
||||
await _countdownBox.clear();
|
||||
if (_goalsBox != null) await _goalsBox!.clear();
|
||||
if (_userBox != null) await _userBox!.clear();
|
||||
if (_countdownBox != null) await _countdownBox!.clear();
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
await _goalsBox.close();
|
||||
await _userBox.close();
|
||||
await _countdownBox.close();
|
||||
if (_goalsBox != null) await _goalsBox!.close();
|
||||
if (_userBox != null) await _userBox!.close();
|
||||
if (_countdownBox != null) await _countdownBox!.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ class AchievementsState {
|
||||
}
|
||||
|
||||
class AchievementsController extends StateNotifier<AchievementsState> {
|
||||
final AchievementsRepository _repository;
|
||||
final AchievementsRepository? _repository;
|
||||
final AuthController _authController;
|
||||
|
||||
AchievementsController(
|
||||
@@ -60,14 +60,16 @@ class AchievementsController extends StateNotifier<AchievementsState> {
|
||||
}
|
||||
|
||||
Future<void> _loadAchievements() async {
|
||||
if (_repository == null) return;
|
||||
|
||||
final userId = _authController.currentUserId;
|
||||
if (userId == null) return;
|
||||
|
||||
state = state.copyWith(isLoading: true);
|
||||
|
||||
try {
|
||||
final available = await _repository.getAvailableAchievements();
|
||||
final unlocked = await _repository.getUserAchievements(userId);
|
||||
final available = await _repository!.getAvailableAchievements();
|
||||
final unlocked = await _repository!.getUserAchievements(userId);
|
||||
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
@@ -86,11 +88,13 @@ class AchievementsController extends StateNotifier<AchievementsState> {
|
||||
AchievementType type,
|
||||
int currentValue,
|
||||
) async {
|
||||
if (_repository == null) return null;
|
||||
|
||||
final userId = _authController.currentUserId;
|
||||
if (userId == null) return null;
|
||||
|
||||
try {
|
||||
final newlyUnlocked = await _repository.checkAndUnlockAchievement(
|
||||
final newlyUnlocked = await _repository!.checkAndUnlockAchievement(
|
||||
userId,
|
||||
type,
|
||||
currentValue,
|
||||
@@ -135,6 +139,9 @@ final achievementsControllerProvider =
|
||||
);
|
||||
});
|
||||
|
||||
final achievementsRepositoryProvider = Provider<AchievementsRepository>((ref) {
|
||||
return AchievementsRepository(supabaseClient);
|
||||
final achievementsRepositoryProvider = Provider<AchievementsRepository?>((ref) {
|
||||
final client = supabaseClient;
|
||||
if (client == null) return null;
|
||||
|
||||
return AchievementsRepository(client);
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ class AIChatState {
|
||||
this.isRecording = false,
|
||||
this.error,
|
||||
this.currentTranscription,
|
||||
this.privacyModeEnabled = true,
|
||||
this.privacyModeEnabled = false,
|
||||
});
|
||||
|
||||
AIChatState copyWith({
|
||||
@@ -154,6 +154,13 @@ class AIChatController extends StateNotifier<AIChatState> {
|
||||
'User privacy mode is DISABLED. Use the following personal context to personalise your coaching:');
|
||||
buffer.writeln('Username: ${user.username}.');
|
||||
|
||||
if (user.heightCm != null) {
|
||||
buffer.writeln('Height: ${user.heightCm!.toStringAsFixed(1)} cm.');
|
||||
}
|
||||
if (user.weightKg != null) {
|
||||
buffer.writeln('Weight: ${user.weightKg!.toStringAsFixed(1)} kg.');
|
||||
}
|
||||
|
||||
if (countdownSummary != null) {
|
||||
buffer.writeln(countdownSummary);
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import '../application/ai_chat_controller.dart';
|
||||
|
||||
class AIChatScreen extends ConsumerStatefulWidget {
|
||||
@@ -64,7 +65,7 @@ class _AIChatScreenState extends ConsumerState<AIChatScreen> {
|
||||
Expanded(
|
||||
child: state.messages.isEmpty
|
||||
? _buildEmptyState(context)
|
||||
: _buildMessagesList(state.messages),
|
||||
: _buildMessagesList(state.messages, state.isLoading),
|
||||
),
|
||||
|
||||
// Error message
|
||||
@@ -174,12 +175,16 @@ class _AIChatScreenState extends ConsumerState<AIChatScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessagesList(List messages) {
|
||||
Widget _buildMessagesList(List messages, bool isLoading) {
|
||||
return ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: messages.length,
|
||||
itemCount: messages.length + (isLoading ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == messages.length && isLoading) {
|
||||
return _buildLoadingIndicator();
|
||||
}
|
||||
|
||||
final message = messages[index];
|
||||
final isUser = message.role == 'user';
|
||||
|
||||
@@ -218,12 +223,27 @@ class _AIChatScreenState extends ConsumerState<AIChatScreen> {
|
||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
child: isUser
|
||||
? Text(
|
||||
message.content,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: isUser
|
||||
? Theme.of(context).colorScheme.onPrimary
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
)
|
||||
: MarkdownBody(
|
||||
data: message.content,
|
||||
styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context)).copyWith(
|
||||
p: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
code: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
codeblockDecoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -245,6 +265,59 @@ class _AIChatScreenState extends ConsumerState<AIChatScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadingIndicator() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
child: Icon(
|
||||
Icons.psychology,
|
||||
size: 20,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.75,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'AI is thinking...',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorMessage(String error, controller) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
|
||||
@@ -271,9 +271,9 @@ final insightsControllerProvider =
|
||||
});
|
||||
|
||||
final goalsRepositoryProvider = Provider<GoalsRepository>((ref) {
|
||||
return GoalsRepository(supabaseClient);
|
||||
return GoalsRepository(supabaseClient ?? (throw Exception("Supabase not initialized")));
|
||||
});
|
||||
|
||||
final countdownRepositoryProvider = Provider<CountdownRepository>((ref) {
|
||||
return CountdownRepository(supabaseClient);
|
||||
return CountdownRepository(supabaseClient ?? (throw Exception("Supabase not initialized")));
|
||||
});
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../data/repositories/auth_repository.dart';
|
||||
import '../../../data/models/user_model.dart';
|
||||
import '../../../data/services/biometric_service.dart';
|
||||
import '../../../core/services/analytics_service.dart';
|
||||
import '../../../bootstrap/supabase_client.dart';
|
||||
import '../../../core/utils/unit_conversion_utils.dart';
|
||||
import 'package:local_auth/local_auth.dart' as local_auth;
|
||||
|
||||
final authControllerProvider = StateNotifierProvider<AuthController, User?>((ref) {
|
||||
return AuthController(ref.read(authRepositoryProvider));
|
||||
});
|
||||
|
||||
final authRepositoryProvider = Provider<AuthRepository>((ref) {
|
||||
return AuthRepository();
|
||||
return AuthRepository(supabaseClient);
|
||||
});
|
||||
|
||||
class AuthController extends StateNotifier<User?> {
|
||||
final AuthRepository _authRepository;
|
||||
final BiometricService _biometricService = BiometricService();
|
||||
final AnalyticsService _analytics = AnalyticsService();
|
||||
|
||||
AuthController(this._authRepository) : super(null) {
|
||||
@@ -46,8 +51,8 @@ class AuthController extends StateNotifier<User?> {
|
||||
_analytics.logSignIn(method: 'email');
|
||||
}
|
||||
|
||||
Future<void> signUpWithEmail(String email, String password, String username) async {
|
||||
await _authRepository.signUpWithEmail(email, password, username);
|
||||
Future<void> signUpWithEmail(String email, String password, String username, {double? heightCm, double? weightKg, int? age, Gender? gender, HeightUnit? heightUnit, WeightUnit? weightUnit}) async {
|
||||
await _authRepository.signUpWithEmail(email, password, username, heightCm: heightCm, weightKg: weightKg, age: age, gender: gender, heightUnit: heightUnit, weightUnit: weightUnit);
|
||||
_analytics.logSignUp(method: 'email');
|
||||
}
|
||||
|
||||
@@ -77,6 +82,12 @@ class AuthController extends StateNotifier<User?> {
|
||||
String? bio,
|
||||
String? avatarUrl,
|
||||
bool? isPublicProfile,
|
||||
double? heightCm,
|
||||
double? weightKg,
|
||||
int? age,
|
||||
Gender? gender,
|
||||
HeightUnit? heightUnit,
|
||||
WeightUnit? weightUnit,
|
||||
}) async {
|
||||
final updatedFields = <String>[];
|
||||
if (username != null) updatedFields.add('username');
|
||||
@@ -86,12 +97,24 @@ class AuthController extends StateNotifier<User?> {
|
||||
updatedFields.add('visibility');
|
||||
_analytics.logProfileVisibilityChanged(isPublic: isPublicProfile);
|
||||
}
|
||||
if (heightCm != null) updatedFields.add('height');
|
||||
if (weightKg != null) updatedFields.add('weight');
|
||||
if (age != null) updatedFields.add('age');
|
||||
if (gender != null) updatedFields.add('gender');
|
||||
if (heightUnit != null) updatedFields.add('height_unit');
|
||||
if (weightUnit != null) updatedFields.add('weight_unit');
|
||||
|
||||
await _authRepository.updateProfile(
|
||||
username: username,
|
||||
bio: bio,
|
||||
avatarUrl: avatarUrl,
|
||||
isPublicProfile: isPublicProfile,
|
||||
heightCm: heightCm,
|
||||
weightKg: weightKg,
|
||||
age: age,
|
||||
gender: gender,
|
||||
heightUnit: heightUnit,
|
||||
weightUnit: weightUnit,
|
||||
);
|
||||
|
||||
if (updatedFields.isNotEmpty) {
|
||||
@@ -99,6 +122,98 @@ class AuthController extends StateNotifier<User?> {
|
||||
}
|
||||
}
|
||||
|
||||
// Biometric Authentication Methods
|
||||
|
||||
/// Check if biometric authentication is available
|
||||
Future<BiometricAvailability> checkBiometricAvailability() async {
|
||||
return await _biometricService.checkAvailability();
|
||||
}
|
||||
|
||||
/// Check if biometric login is enabled
|
||||
Future<bool> isBiometricEnabled() async {
|
||||
return await _biometricService.isBiometricEnabled();
|
||||
}
|
||||
|
||||
/// Enable biometric login for current user
|
||||
Future<bool> enableBiometric() async {
|
||||
final userId = currentUserId;
|
||||
if (userId == null) return false;
|
||||
|
||||
final success = await _biometricService.enableBiometric(userId);
|
||||
if (success) {
|
||||
_analytics.logEvent('biometric_enabled');
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
/// Disable biometric login
|
||||
Future<bool> disableBiometric() async {
|
||||
final success = await _biometricService.disableBiometric();
|
||||
if (success) {
|
||||
_analytics.logEvent('biometric_disabled');
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
/// Authenticate with biometrics and sign in
|
||||
Future<bool> signInWithBiometric() async {
|
||||
try {
|
||||
// Check if biometric is enabled
|
||||
final isEnabled = await _biometricService.isBiometricEnabled();
|
||||
if (!isEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get the stored user ID
|
||||
final biometricUserId = await _biometricService.getBiometricUserId();
|
||||
if (biometricUserId == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Authenticate with biometrics
|
||||
final authenticated = await _biometricService.authenticate(
|
||||
reason: 'Sign in to your 1356 day challenge',
|
||||
localizedReason: 'Use your biometric to quickly access your challenge',
|
||||
);
|
||||
|
||||
if (authenticated) {
|
||||
// Try to restore session for the stored user
|
||||
await _authRepository.refreshSession();
|
||||
|
||||
// Verify the current user matches the stored biometric user
|
||||
final currentUser = _authRepository.currentUser;
|
||||
final currentUserId = currentUser?.id;
|
||||
if (currentUserId == biometricUserId) {
|
||||
_analytics.logSignIn(method: 'biometric');
|
||||
return true;
|
||||
} else {
|
||||
// User mismatch, disable biometric
|
||||
await disableBiometric();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get biometric status message
|
||||
Future<String> getBiometricStatusMessage() async {
|
||||
return await _biometricService.getBiometricStatusMessage();
|
||||
}
|
||||
|
||||
/// Get available biometric types
|
||||
Future<List<local_auth.BiometricType>> getAvailableBiometrics() async {
|
||||
return await _biometricService.getAvailableBiometrics();
|
||||
}
|
||||
|
||||
/// Get primary biometric type
|
||||
Future<local_auth.BiometricType?> getPrimaryBiometricType() async {
|
||||
return await _biometricService.getPrimaryBiometricType();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_authRepository.dispose();
|
||||
|
||||
@@ -102,7 +102,7 @@ class _AuthChoiceScreenState extends ConsumerState<AuthChoiceScreen> {
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'LifeTimer',
|
||||
'1356',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineLarge
|
||||
|
||||
@@ -1,20 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../application/auth_controller.dart';
|
||||
import '../../onboarding/application/onboarding_controller.dart';
|
||||
import '../../profile/application/profile_controller.dart';
|
||||
import 'auth_showcase_screen.dart';
|
||||
import '../../onboarding/presentation/onboarding_intro_screen.dart';
|
||||
import '../../profile/presentation/profile_setup_screen.dart';
|
||||
import '../../countdown/presentation/home_countdown_screen.dart';
|
||||
import '../../../bootstrap/supabase_client.dart';
|
||||
|
||||
class AuthGate extends ConsumerWidget {
|
||||
class AuthGate extends ConsumerStatefulWidget {
|
||||
const AuthGate({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<AuthGate> createState() => _AuthGateState();
|
||||
}
|
||||
|
||||
class _AuthGateState extends ConsumerState<AuthGate> {
|
||||
bool _isCheckingProfile = false;
|
||||
bool _profileSetupComplete = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final authState = ref.watch(authControllerProvider);
|
||||
final onboardingState = ref.watch(onboardingControllerProvider);
|
||||
|
||||
// If no backend is configured and there is no overridden auth state,
|
||||
// keep the app usable by continuing through the local onboarding flow.
|
||||
if (supabaseClient == null && authState == null) {
|
||||
if (!onboardingState) {
|
||||
return const OnboardingIntroScreen();
|
||||
}
|
||||
return const HomeCountdownScreen();
|
||||
}
|
||||
|
||||
if (authState == null) {
|
||||
return const AuthShowcaseScreen();
|
||||
}
|
||||
|
||||
// If user is authenticated but hasn't completed onboarding
|
||||
if (!onboardingState) {
|
||||
return const OnboardingIntroScreen();
|
||||
}
|
||||
|
||||
// Check if profile setup is complete
|
||||
if (!_isCheckingProfile && !_profileSetupComplete) {
|
||||
_isCheckingProfile = true;
|
||||
_checkProfileSetup(authState.id);
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
|
||||
// If profile setup is not complete, show profile setup screen
|
||||
if (!_profileSetupComplete) {
|
||||
return const ProfileSetupScreen();
|
||||
}
|
||||
|
||||
// User is authenticated and has completed onboarding and profile setup
|
||||
return const HomeCountdownScreen();
|
||||
}
|
||||
|
||||
Future<void> _checkProfileSetup(String userId) async {
|
||||
final isComplete = await ref.read(profileControllerProvider.notifier).isProfileSetupComplete(userId);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_profileSetupComplete = isComplete;
|
||||
_isCheckingProfile = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ class AuthShowcaseScreen extends ConsumerWidget {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'LifeTimer helps you design a 1356-day experiment, focus on a small set of meaningful goals, and see time as a single bold countdown.',
|
||||
'1356 helps you design a 1356-day experiment, focus on a small set of meaningful goals, and see time as a single bold countdown.',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha:0.7),
|
||||
height: 1.6,
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../application/auth_controller.dart';
|
||||
import '../../../data/services/biometric_service.dart';
|
||||
import '../../../core/widgets/app_scaffold.dart';
|
||||
import '../../../core/widgets/primary_button.dart';
|
||||
import '../../../core/utils/validators.dart';
|
||||
import '../application/auth_controller.dart';
|
||||
|
||||
class SignInScreen extends ConsumerStatefulWidget {
|
||||
const SignInScreen({super.key});
|
||||
@@ -19,8 +21,86 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final BiometricService _biometricService = BiometricService();
|
||||
bool _isLoading = false;
|
||||
bool _obscurePassword = true;
|
||||
bool _isBiometricAvailable = false;
|
||||
bool _isBiometricEnabled = false;
|
||||
bool _isBiometricLoading = false;
|
||||
bool _showEmailVerificationMessage = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_checkBiometricStatus();
|
||||
_checkIfComingFromRegistration();
|
||||
}
|
||||
|
||||
void _checkIfComingFromRegistration() async {
|
||||
// Check if user navigated from registration by checking shared preferences
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final justRegistered = prefs.getBool('just_registered') ?? false;
|
||||
final registrationTime = prefs.getInt('registration_time') ?? 0;
|
||||
|
||||
if (mounted) {
|
||||
// Show message if registration happened in the last 5 minutes
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final fiveMinutesAgo = now - (5 * 60 * 1000);
|
||||
|
||||
if (justRegistered && registrationTime > fiveMinutesAgo) {
|
||||
setState(() {
|
||||
_showEmailVerificationMessage = true;
|
||||
});
|
||||
// Clear the flag so it doesn't show again
|
||||
await prefs.remove('just_registered');
|
||||
await prefs.remove('registration_time');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkBiometricStatus() async {
|
||||
try {
|
||||
final availability = await _biometricService.checkAvailability();
|
||||
final isEnabled = await _biometricService.isBiometricEnabled();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isBiometricAvailable = availability == BiometricAvailability.available;
|
||||
_isBiometricEnabled = isEnabled;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Biometric not available, ignore
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleBiometricSignIn() async {
|
||||
setState(() => _isBiometricLoading = true);
|
||||
try {
|
||||
final authController = ref.read(authControllerProvider.notifier);
|
||||
final success = await authController.signInWithBiometric();
|
||||
|
||||
if (success) {
|
||||
// Navigation will be handled by AuthGate
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Biometric login failed')),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Biometric login error: ${e.toString()}')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isBiometricLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleSignIn() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
@@ -111,7 +191,7 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'LifeTimer',
|
||||
'1356',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
@@ -172,6 +252,45 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
|
||||
.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Email verification reminder message
|
||||
if (_showEmailVerificationMessage)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.blue.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Colors.blue.shade700, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Please verify your email before signing in. Check your inbox for the verification link.',
|
||||
style: TextStyle(
|
||||
color: Colors.blue.shade700,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_showEmailVerificationMessage = false;
|
||||
});
|
||||
},
|
||||
color: Colors.blue.shade700,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (_showEmailVerificationMessage)
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 24),
|
||||
Semantics(
|
||||
label: 'Email address field',
|
||||
@@ -240,6 +359,39 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Biometric Login Button
|
||||
if (_isBiometricAvailable && _isBiometricEnabled)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _isBiometricLoading ? null : _handleBiometricSignIn,
|
||||
icon: _isBiometricLoading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.fingerprint),
|
||||
label: Text(_isBiometricLoading ? 'Authenticating...' : 'Sign in with Biometric'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
foregroundColor: Theme.of(context).colorScheme.onSurface,
|
||||
elevation: 0,
|
||||
side: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (_isBiometricAvailable && _isBiometricEnabled)
|
||||
const SizedBox(height: 12),
|
||||
|
||||
PrimaryButton(
|
||||
onPressed: _handleSignIn,
|
||||
text: _isLoading ? 'Signing in...' : 'Sign In',
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../../../core/widgets/app_scaffold.dart';
|
||||
import '../../../core/widgets/primary_button.dart';
|
||||
import '../../../core/widgets/unit_input_field.dart';
|
||||
import '../../../core/utils/validators.dart';
|
||||
import '../application/auth_controller.dart';
|
||||
import '../../../core/utils/unit_conversion_utils.dart';
|
||||
|
||||
class SignUpScreen extends ConsumerStatefulWidget {
|
||||
const SignUpScreen({super.key});
|
||||
@@ -21,20 +24,57 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
||||
final _passwordController = TextEditingController();
|
||||
final _confirmPasswordController = TextEditingController();
|
||||
final _usernameController = TextEditingController();
|
||||
final _ageController = TextEditingController();
|
||||
Gender? _selectedGender;
|
||||
bool _isLoading = false;
|
||||
bool _obscurePassword = true;
|
||||
bool _obscureConfirmPassword = true;
|
||||
bool _showAdditionalInfo = false;
|
||||
double? _heightCm;
|
||||
double? _weightKg;
|
||||
HeightUnit _selectedHeightUnit = HeightUnit.metric;
|
||||
WeightUnit _selectedWeightUnit = WeightUnit.metric;
|
||||
|
||||
Future<void> _handleSignUp() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final age = _ageController.text.trim().isNotEmpty
|
||||
? int.tryParse(_ageController.text.trim())
|
||||
: null;
|
||||
|
||||
await ref.read(authControllerProvider.notifier).signUpWithEmail(
|
||||
_emailController.text.trim(),
|
||||
_passwordController.text,
|
||||
_usernameController.text.trim(),
|
||||
heightCm: _heightCm,
|
||||
weightKg: _weightKg,
|
||||
age: age,
|
||||
gender: _selectedGender,
|
||||
heightUnit: _selectedHeightUnit,
|
||||
weightUnit: _selectedWeightUnit,
|
||||
);
|
||||
|
||||
// Show success message and navigate to login screen
|
||||
if (mounted) {
|
||||
// Mark that registration just happened for the login screen
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('just_registered', true);
|
||||
await prefs.setInt('registration_time', DateTime.now().millisecondsSinceEpoch);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Account created successfully! Please check your email and verify it before signing in.'),
|
||||
backgroundColor: Colors.green,
|
||||
duration: Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
// Navigate to login screen after successful registration
|
||||
context.pushReplacement('/sign-in');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -54,6 +94,7 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
||||
_passwordController.dispose();
|
||||
_confirmPasswordController.dispose();
|
||||
_usernameController.dispose();
|
||||
_ageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -91,7 +132,7 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'LifeTimer',
|
||||
'1356',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
@@ -235,6 +276,121 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
||||
onFieldSubmitted: (_) => _handleSignUp(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Additional optional info section
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_showAdditionalInfo = !_showAdditionalInfo;
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_showAdditionalInfo
|
||||
? Icons.expand_less
|
||||
: Icons.expand_more,
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Add more info for better recommendations',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (_showAdditionalInfo) ...[
|
||||
const SizedBox(height: 16),
|
||||
UnitInputField(
|
||||
labelText: 'Height',
|
||||
prefixIcon: Icons.height_outlined,
|
||||
helperText: 'Optional: For personalized recommendations',
|
||||
enabled: !_isLoading,
|
||||
onValueChanged: (value) {
|
||||
setState(() {
|
||||
_heightCm = value;
|
||||
});
|
||||
},
|
||||
onUnitChanged: (unit) {
|
||||
setState(() {
|
||||
_selectedHeightUnit = unit as HeightUnit;
|
||||
});
|
||||
},
|
||||
isHeight: true,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
UnitInputField(
|
||||
labelText: 'Weight',
|
||||
prefixIcon: Icons.monitor_weight_outlined,
|
||||
helperText: 'Optional: For personalized recommendations',
|
||||
enabled: !_isLoading,
|
||||
onValueChanged: (value) {
|
||||
setState(() {
|
||||
_weightKg = value;
|
||||
});
|
||||
},
|
||||
onUnitChanged: (unit) {
|
||||
setState(() {
|
||||
_selectedWeightUnit = unit as WeightUnit;
|
||||
});
|
||||
},
|
||||
isHeight: false,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _ageController,
|
||||
keyboardType: TextInputType.number,
|
||||
textInputAction: TextInputAction.next,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Age',
|
||||
prefixIcon: Icon(Icons.cake_outlined),
|
||||
helperText: 'Optional: For age-appropriate recommendations',
|
||||
),
|
||||
enabled: !_isLoading,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<Gender>(
|
||||
value: _selectedGender,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Gender',
|
||||
prefixIcon: Icon(Icons.person_outline),
|
||||
helperText: 'Optional: For personalized recommendations',
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: Gender.male,
|
||||
child: Text('Male'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: Gender.female,
|
||||
child: Text('Female'),
|
||||
),
|
||||
],
|
||||
onChanged: _isLoading ? null : (Gender? value) {
|
||||
setState(() {
|
||||
_selectedGender = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
PrimaryButton(
|
||||
onPressed: _handleSignUp,
|
||||
text: _isLoading
|
||||
|
||||
@@ -93,7 +93,7 @@ class CalendarController extends StateNotifier<CalendarState> {
|
||||
}
|
||||
|
||||
final calendarRepositoryProvider = Provider<CalendarRepository>((ref) {
|
||||
return CalendarRepository(supabaseClient);
|
||||
return CalendarRepository(supabaseClient ?? (throw Exception("Supabase not initialized")));
|
||||
});
|
||||
|
||||
final calendarControllerProvider =
|
||||
|
||||
@@ -522,6 +522,7 @@ Future<void> _showAddCalendarEntrySheet(
|
||||
],
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
final navigator = Navigator.of(sheetContext);
|
||||
await ref
|
||||
.read(calendarControllerProvider.notifier)
|
||||
.addEntry(
|
||||
@@ -529,8 +530,8 @@ Future<void> _showAddCalendarEntrySheet(
|
||||
note: noteController.text,
|
||||
goalId: selectedGoalId,
|
||||
);
|
||||
if (Navigator.of(sheetContext).canPop()) {
|
||||
Navigator.of(sheetContext).pop();
|
||||
if (navigator.canPop()) {
|
||||
navigator.pop();
|
||||
}
|
||||
},
|
||||
child: const Text('Save to calendar'),
|
||||
|
||||
@@ -149,7 +149,7 @@ class CountdownLoaded extends CountdownState {
|
||||
}
|
||||
|
||||
final countdownRepositoryProvider = Provider<CountdownRepository>((ref) {
|
||||
return CountdownRepository(supabaseClient);
|
||||
return CountdownRepository(supabaseClient ?? (throw Exception("Supabase not initialized")));
|
||||
});
|
||||
|
||||
final countdownControllerProvider = StateNotifierProvider<CountdownController, CountdownState>((ref) {
|
||||
|
||||
@@ -28,9 +28,7 @@ class _HomeCountdownScreenState extends ConsumerState<HomeCountdownScreen> {
|
||||
? achievementsState.level
|
||||
: null;
|
||||
|
||||
return AppScaffold(
|
||||
body: SafeArea(
|
||||
child: countdownState.isLoading
|
||||
final child = countdownState.isLoading
|
||||
? const Center(child: LoadingIndicator())
|
||||
: countdownState.error != null
|
||||
? Center(
|
||||
@@ -51,6 +49,13 @@ class _HomeCountdownScreenState extends ConsumerState<HomeCountdownScreen> {
|
||||
: _CountdownActiveScreen(
|
||||
user: countdownState.user!,
|
||||
level: level,
|
||||
);
|
||||
|
||||
return AppScaffold(
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 30.0, bottom: 30.0),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
@@ -156,7 +161,7 @@ class _CountdownActiveScreen extends StatelessWidget {
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
padding: const EdgeInsets.only(top: 30.0, left: 24.0, right: 24.0, bottom: 30.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
|
||||
@@ -154,7 +154,11 @@ class GoalsState {
|
||||
}
|
||||
|
||||
final goalsRepositoryProvider = Provider<GoalsRepository>((ref) {
|
||||
return GoalsRepository(supabaseClient);
|
||||
final client = supabaseClient;
|
||||
if (client == null) {
|
||||
throw Exception('Supabase not initialized - goals repository unavailable');
|
||||
}
|
||||
return GoalsRepository(client);
|
||||
});
|
||||
|
||||
final goalsControllerProvider = StateNotifierProvider<GoalsController, GoalsState>((ref) {
|
||||
|
||||
@@ -7,10 +7,10 @@ class OnboardingController extends StateNotifier<bool> {
|
||||
static const String _onboardingKey = 'onboarding_completed';
|
||||
|
||||
OnboardingController() : super(false) {
|
||||
_loadOnboardingStatus();
|
||||
loadOnboardingStatus();
|
||||
}
|
||||
|
||||
Future<void> _loadOnboardingStatus() async {
|
||||
Future<void> loadOnboardingStatus() async {
|
||||
try {
|
||||
final box = await Hive.openBox('app_settings');
|
||||
final completed = box.get(_onboardingKey, defaultValue: false);
|
||||
|
||||
@@ -17,10 +17,12 @@ class OnboardingHowItWorksScreen extends ConsumerWidget {
|
||||
return AppScaffold(
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
padding: const EdgeInsets.only(top: 30.0, left: 24.0, right: 24.0, bottom: 30.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Progress Bar and Navigation
|
||||
const _OnboardingProgress(currentStep: 2, totalSteps: 3),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'How It Works',
|
||||
@@ -50,14 +52,28 @@ class OnboardingHowItWorksScreen extends ConsumerWidget {
|
||||
description: 'The countdown begins immediately. Track your progress and make every day count.',
|
||||
icon: Icons.timer,
|
||||
),
|
||||
const Spacer(),
|
||||
PrimaryButton(
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: () => context.pop(),
|
||||
child: const Text('Back'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: PrimaryButton(
|
||||
onPressed: () {
|
||||
controller.completeStep('how_it_works');
|
||||
context.push('/onboarding/motivation');
|
||||
},
|
||||
text: 'Continue',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
@@ -159,3 +175,44 @@ class _StepCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OnboardingProgress extends StatelessWidget {
|
||||
final int currentStep;
|
||||
final int totalSteps;
|
||||
|
||||
const _OnboardingProgress({
|
||||
required this.currentStep,
|
||||
required this.totalSteps,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Step $currentStep of $totalSteps',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.pop(),
|
||||
child: const Text('Back'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
LinearProgressIndicator(
|
||||
value: currentStep / totalSteps,
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,11 +16,14 @@ class OnboardingIntroScreen extends ConsumerWidget {
|
||||
|
||||
return AppScaffold(
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
padding: const EdgeInsets.only(top: 20.0, left: 24.0, right: 24.0, bottom: 20.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Progress Bar and Navigation
|
||||
const _OnboardingProgress(currentStep: 1, totalSteps: 3),
|
||||
const SizedBox(height: 48),
|
||||
const Icon(
|
||||
Icons.timer_outlined,
|
||||
@@ -29,7 +32,7 @@ class OnboardingIntroScreen extends ConsumerWidget {
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
'Welcome to LifeTimer',
|
||||
'Welcome to 1356',
|
||||
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
@@ -62,29 +65,39 @@ class OnboardingIntroScreen extends ConsumerWidget {
|
||||
title: 'Track Progress',
|
||||
description: 'Watch yourself grow day by day',
|
||||
),
|
||||
const Spacer(),
|
||||
PrimaryButton(
|
||||
onPressed: () {
|
||||
controller.completeStep('intro');
|
||||
context.push('/onboarding/how-it-works');
|
||||
},
|
||||
text: 'Get Started',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
const SizedBox(height: 48),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: () async {
|
||||
await controller.skipOnboarding();
|
||||
if (context.mounted) {
|
||||
context.push('/home');
|
||||
}
|
||||
},
|
||||
child: const Text('Skip onboarding'),
|
||||
child: const Text('Skip'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: PrimaryButton(
|
||||
onPressed: () {
|
||||
controller.completeStep('intro');
|
||||
context.push('/onboarding/how-it-works');
|
||||
},
|
||||
text: 'Get Started',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -105,10 +118,10 @@ class _FeatureCard extends StatelessWidget {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3),
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.2),
|
||||
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
@@ -144,3 +157,44 @@ class _FeatureCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OnboardingProgress extends StatelessWidget {
|
||||
final int currentStep;
|
||||
final int totalSteps;
|
||||
|
||||
const _OnboardingProgress({
|
||||
required this.currentStep,
|
||||
required this.totalSteps,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Step $currentStep of $totalSteps',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.pop(),
|
||||
child: const Text('Back'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
LinearProgressIndicator(
|
||||
value: currentStep / totalSteps,
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,11 +16,14 @@ class OnboardingMotivationScreen extends ConsumerWidget {
|
||||
|
||||
return AppScaffold(
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
padding: const EdgeInsets.only(top: 30.0, left: 24.0, right: 24.0, bottom: 30.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Progress Bar and Navigation
|
||||
const _OnboardingProgress(currentStep: 3, totalSteps: 3),
|
||||
const SizedBox(height: 24),
|
||||
const Icon(
|
||||
Icons.psychology_outlined,
|
||||
@@ -65,7 +68,7 @@ class OnboardingMotivationScreen extends ConsumerWidget {
|
||||
title: 'Celebrate Wins',
|
||||
description: 'Every achievement is worth celebrating.',
|
||||
),
|
||||
const Spacer(),
|
||||
const SizedBox(height: 24),
|
||||
PrimaryButton(
|
||||
onPressed: () async {
|
||||
controller.completeStep('motivation');
|
||||
@@ -81,6 +84,7 @@ class OnboardingMotivationScreen extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -101,10 +105,10 @@ class _MotivationCard extends StatelessWidget {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3),
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.2),
|
||||
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
@@ -140,3 +144,44 @@ class _MotivationCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OnboardingProgress extends StatelessWidget {
|
||||
final int currentStep;
|
||||
final int totalSteps;
|
||||
|
||||
const _OnboardingProgress({
|
||||
required this.currentStep,
|
||||
required this.totalSteps,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Step $currentStep of $totalSteps',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.pop(),
|
||||
child: const Text('Back'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
LinearProgressIndicator(
|
||||
value: currentStep / totalSteps,
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,30 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
|
||||
import '../../../data/models/user_model.dart' as app;
|
||||
import '../../../data/repositories/user_repository.dart';
|
||||
import '../../../bootstrap/supabase_client.dart';
|
||||
import '../../../core/errors/failure.dart';
|
||||
import '../../../core/utils/unit_conversion_utils.dart';
|
||||
|
||||
final profileControllerProvider = StateNotifierProvider<ProfileController, ProfileState>((ref) {
|
||||
final client = supabase.Supabase.instance.client;
|
||||
final repository = UserRepository(client);
|
||||
final client = supabaseClient;
|
||||
final repository = client != null ? UserRepository(client) : null;
|
||||
return ProfileController(repository);
|
||||
});
|
||||
|
||||
class ProfileController extends StateNotifier<ProfileState> {
|
||||
final UserRepository _repository;
|
||||
final UserRepository? _repository;
|
||||
|
||||
ProfileController(this._repository) : super(const ProfileState.initial());
|
||||
|
||||
Future<void> loadProfile(String userId) async {
|
||||
if (_repository == null) {
|
||||
state = const ProfileState.error('Profile is unavailable while offline.');
|
||||
return;
|
||||
}
|
||||
|
||||
state = const ProfileState.loading();
|
||||
try {
|
||||
final user = await _repository.getProfile(userId);
|
||||
final user = await _repository!.getProfile(userId);
|
||||
state = ProfileState.loaded(user);
|
||||
} on Failure catch (failure) {
|
||||
state = ProfileState.error(failure.message);
|
||||
@@ -28,15 +34,20 @@ class ProfileController extends StateNotifier<ProfileState> {
|
||||
}
|
||||
|
||||
Future<void> updateUsername(String userId, String username) async {
|
||||
if (_repository == null) {
|
||||
state = const ProfileState.error('Profile is unavailable while offline.');
|
||||
return;
|
||||
}
|
||||
|
||||
state = const ProfileState.loading();
|
||||
try {
|
||||
final isAvailable = await _repository.isUsernameAvailable(username);
|
||||
final isAvailable = await _repository!.isUsernameAvailable(username);
|
||||
if (!isAvailable) {
|
||||
state = const ProfileState.error('Username is already taken');
|
||||
return;
|
||||
}
|
||||
|
||||
final updatedUser = await _repository.updateProfile(
|
||||
final updatedUser = await _repository!.updateProfile(
|
||||
userId: userId,
|
||||
username: username,
|
||||
);
|
||||
@@ -49,9 +60,14 @@ class ProfileController extends StateNotifier<ProfileState> {
|
||||
}
|
||||
|
||||
Future<void> updateBio(String userId, String bio) async {
|
||||
if (_repository == null) {
|
||||
state = const ProfileState.error('Profile is unavailable while offline.');
|
||||
return;
|
||||
}
|
||||
|
||||
state = const ProfileState.loading();
|
||||
try {
|
||||
final updatedUser = await _repository.updateProfile(
|
||||
final updatedUser = await _repository!.updateProfile(
|
||||
userId: userId,
|
||||
bio: bio,
|
||||
);
|
||||
@@ -64,9 +80,14 @@ class ProfileController extends StateNotifier<ProfileState> {
|
||||
}
|
||||
|
||||
Future<void> updateAvatarUrl(String userId, String avatarUrl) async {
|
||||
if (_repository == null) {
|
||||
state = const ProfileState.error('Profile is unavailable while offline.');
|
||||
return;
|
||||
}
|
||||
|
||||
state = const ProfileState.loading();
|
||||
try {
|
||||
final updatedUser = await _repository.updateProfile(
|
||||
final updatedUser = await _repository!.updateProfile(
|
||||
userId: userId,
|
||||
avatarUrl: avatarUrl,
|
||||
);
|
||||
@@ -79,12 +100,17 @@ class ProfileController extends StateNotifier<ProfileState> {
|
||||
}
|
||||
|
||||
Future<void> toggleProfileVisibility(String userId) async {
|
||||
if (_repository == null) {
|
||||
state = const ProfileState.error('Profile is unavailable while offline.');
|
||||
return;
|
||||
}
|
||||
|
||||
final currentState = state;
|
||||
if (currentState.user == null) return;
|
||||
|
||||
state = const ProfileState.loading();
|
||||
try {
|
||||
final updatedUser = await _repository.updateProfile(
|
||||
final updatedUser = await _repository!.updateProfile(
|
||||
userId: userId,
|
||||
isPublicProfile: !currentState.user!.isPublicProfile,
|
||||
);
|
||||
@@ -105,16 +131,27 @@ class ProfileController extends StateNotifier<ProfileState> {
|
||||
String? instagramHandle,
|
||||
String? tiktokHandle,
|
||||
String? websiteUrl,
|
||||
Gender? gender,
|
||||
DateTime? birthDate,
|
||||
double? heightCm,
|
||||
double? weightKg,
|
||||
HeightUnit heightUnit = HeightUnit.metric,
|
||||
WeightUnit weightUnit = WeightUnit.metric,
|
||||
}) async {
|
||||
if (_repository == null) {
|
||||
state = const ProfileState.error('Profile is unavailable while offline.');
|
||||
return;
|
||||
}
|
||||
|
||||
state = const ProfileState.loading();
|
||||
try {
|
||||
final isAvailable = await _repository.isUsernameAvailable(username);
|
||||
final isAvailable = await _repository!.isUsernameAvailable(username);
|
||||
if (!isAvailable) {
|
||||
state = const ProfileState.error('Username is already taken');
|
||||
return;
|
||||
}
|
||||
|
||||
final updatedUser = await _repository.updateProfile(
|
||||
final updatedUser = await _repository!.updateProfile(
|
||||
userId: userId,
|
||||
username: username,
|
||||
bio: bio,
|
||||
@@ -123,6 +160,12 @@ class ProfileController extends StateNotifier<ProfileState> {
|
||||
instagramHandle: instagramHandle,
|
||||
tiktokHandle: tiktokHandle,
|
||||
websiteUrl: websiteUrl,
|
||||
gender: gender,
|
||||
birthDate: birthDate,
|
||||
heightCm: heightCm,
|
||||
weightKg: weightKg,
|
||||
heightUnit: heightUnit,
|
||||
weightUnit: weightUnit,
|
||||
);
|
||||
state = ProfileState.loaded(updatedUser);
|
||||
} on Failure catch (failure) {
|
||||
@@ -131,6 +174,19 @@ class ProfileController extends StateNotifier<ProfileState> {
|
||||
state = ProfileState.error(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> isProfileSetupComplete(String userId) async {
|
||||
if (_repository == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
final user = await _repository!.getProfile(userId);
|
||||
return user.username.isNotEmpty;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ProfileState {
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
|
||||
import '../../../bootstrap/supabase_client.dart';
|
||||
import '../../../core/widgets/app_scaffold.dart';
|
||||
import '../../../core/widgets/loading_indicator.dart';
|
||||
import '../../../core/widgets/empty_state.dart';
|
||||
@@ -23,7 +23,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final userId = supabase.Supabase.instance.client.auth.currentUser?.id;
|
||||
final userId = currentSupabaseUserId;
|
||||
if (userId != null) {
|
||||
ref.read(profileControllerProvider.notifier).loadProfile(userId);
|
||||
}
|
||||
@@ -33,10 +33,11 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
final profileState = ref.watch(profileControllerProvider);
|
||||
final achievementsState = ref.watch(achievementsControllerProvider);
|
||||
final userId = supabase.Supabase.instance.client.auth.currentUser?.id;
|
||||
final userId = currentSupabaseUserId;
|
||||
|
||||
if (userId == null) {
|
||||
return AppScaffold(
|
||||
title: 'Profile',
|
||||
body: Semantics(
|
||||
label: 'Not signed in',
|
||||
child: const EmptyState(
|
||||
@@ -105,6 +106,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||
SliverAppBar(
|
||||
expandedHeight: 200,
|
||||
pinned: true,
|
||||
title: const Text('Profile'),
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: Container(
|
||||
decoration: BoxDecoration(
|
||||
@@ -408,7 +410,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||
);
|
||||
|
||||
if (confirmed == true && context.mounted) {
|
||||
await supabase.Supabase.instance.client.auth.signOut();
|
||||
await signOutCurrentSupabaseUser();
|
||||
if (context.mounted) {
|
||||
context.go('/');
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
|
||||
import '../../../core/widgets/app_scaffold.dart';
|
||||
import '../../../core/widgets/primary_button.dart';
|
||||
import '../../../core/utils/validators.dart';
|
||||
import '../../../core/utils/unit_conversion_utils.dart';
|
||||
import '../application/profile_controller.dart';
|
||||
|
||||
class ProfileSetupScreen extends ConsumerStatefulWidget {
|
||||
@@ -25,6 +26,8 @@ class _ProfileSetupScreenState extends ConsumerState<ProfileSetupScreen> {
|
||||
final _instagramController = TextEditingController();
|
||||
final _tiktokController = TextEditingController();
|
||||
final _websiteController = TextEditingController();
|
||||
final _heightController = TextEditingController();
|
||||
final _weightController = TextEditingController();
|
||||
|
||||
dynamic _avatarFile;
|
||||
String? _avatarUrl;
|
||||
@@ -33,6 +36,11 @@ class _ProfileSetupScreenState extends ConsumerState<ProfileSetupScreen> {
|
||||
bool _isUsernameAvailable = true;
|
||||
String? _usernameError;
|
||||
|
||||
Gender? _selectedGender;
|
||||
DateTime? _selectedBirthDate;
|
||||
HeightUnit _selectedHeightUnit = HeightUnit.metric;
|
||||
WeightUnit _selectedWeightUnit = WeightUnit.metric;
|
||||
|
||||
final ImagePicker _imagePicker = ImagePicker();
|
||||
|
||||
Future<void> _pickAvatar() async {
|
||||
@@ -152,6 +160,18 @@ class _ProfileSetupScreenState extends ConsumerState<ProfileSetupScreen> {
|
||||
uploadedAvatarUrl = await _uploadAvatar();
|
||||
}
|
||||
|
||||
// Parse height and weight values
|
||||
double? heightCm;
|
||||
double? weightKg;
|
||||
|
||||
if (_heightController.text.isNotEmpty) {
|
||||
heightCm = UnitConversionUtils.parseHeight(_heightController.text, _selectedHeightUnit);
|
||||
}
|
||||
|
||||
if (_weightController.text.isNotEmpty) {
|
||||
weightKg = UnitConversionUtils.parseWeight(_weightController.text, _selectedWeightUnit);
|
||||
}
|
||||
|
||||
await ref.read(profileControllerProvider.notifier).completeProfileSetup(
|
||||
userId: userId,
|
||||
username: _usernameController.text.trim(),
|
||||
@@ -169,10 +189,16 @@ class _ProfileSetupScreenState extends ConsumerState<ProfileSetupScreen> {
|
||||
websiteUrl: _websiteController.text.trim().isEmpty
|
||||
? null
|
||||
: _websiteController.text.trim(),
|
||||
gender: _selectedGender,
|
||||
birthDate: _selectedBirthDate,
|
||||
heightCm: heightCm,
|
||||
weightKg: weightKg,
|
||||
heightUnit: _selectedHeightUnit,
|
||||
weightUnit: _selectedWeightUnit,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
context.go('/onboarding');
|
||||
context.go('/home');
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
@@ -195,6 +221,8 @@ class _ProfileSetupScreenState extends ConsumerState<ProfileSetupScreen> {
|
||||
_instagramController.dispose();
|
||||
_tiktokController.dispose();
|
||||
_websiteController.dispose();
|
||||
_heightController.dispose();
|
||||
_weightController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -341,6 +369,170 @@ class _ProfileSetupScreenState extends ConsumerState<ProfileSetupScreen> {
|
||||
),
|
||||
enabled: !_isLoading,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Biometric Information (Optional)',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Gender Field
|
||||
DropdownButtonFormField<Gender>(
|
||||
initialValue: _selectedGender,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Gender',
|
||||
prefixIcon: Icon(Icons.person_outline),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: Gender.values.map((gender) {
|
||||
return DropdownMenuItem(
|
||||
value: gender,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(gender.emoji),
|
||||
const SizedBox(width: 8),
|
||||
Text(gender.displayName),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: !_isLoading ? (Gender? value) {
|
||||
setState(() {
|
||||
_selectedGender = value;
|
||||
});
|
||||
} : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Birth Date Field
|
||||
InkWell(
|
||||
onTap: !_isLoading ? () async {
|
||||
final DateTime? picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _selectedBirthDate ?? DateTime.now().subtract(const Duration(days: 365 * 25)),
|
||||
firstDate: DateTime.now().subtract(const Duration(days: 365 * 120)),
|
||||
lastDate: DateTime.now().subtract(const Duration(days: 365 * 13)),
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() {
|
||||
_selectedBirthDate = picked;
|
||||
});
|
||||
}
|
||||
} : null,
|
||||
child: InputDecorator(
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Birth Date',
|
||||
prefixIcon: Icon(Icons.cake_outlined),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
child: Text(
|
||||
_selectedBirthDate != null
|
||||
? '${_selectedBirthDate!.day}/${_selectedBirthDate!.month}/${_selectedBirthDate!.year}'
|
||||
: 'Select your birth date',
|
||||
style: TextStyle(
|
||||
color: _selectedBirthDate != null
|
||||
? null
|
||||
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Height and Weight Row
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _heightController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Height (${_selectedHeightUnit == HeightUnit.metric ? 'cm' : 'ft/in'})',
|
||||
prefixIcon: const Icon(Icons.height_outlined),
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: PopupMenuButton<HeightUnit>(
|
||||
icon: const Icon(Icons.tune),
|
||||
onSelected: (HeightUnit unit) {
|
||||
setState(() {
|
||||
_selectedHeightUnit = unit;
|
||||
// Convert existing value if needed
|
||||
if (_heightController.text.isNotEmpty) {
|
||||
final currentValue = double.tryParse(_heightController.text);
|
||||
if (currentValue != null) {
|
||||
double convertedValue;
|
||||
if (unit == HeightUnit.imperial && _selectedHeightUnit == HeightUnit.metric) {
|
||||
convertedValue = UnitConversionUtils.cmToInches(currentValue);
|
||||
_heightController.text = convertedValue.toStringAsFixed(1);
|
||||
} else if (unit == HeightUnit.metric && _selectedHeightUnit == HeightUnit.imperial) {
|
||||
convertedValue = UnitConversionUtils.inchesToCm(currentValue);
|
||||
_heightController.text = convertedValue.toStringAsFixed(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(value: HeightUnit.metric, child: Text('Metric (cm)')),
|
||||
const PopupMenuItem(value: HeightUnit.imperial, child: Text('Imperial (ft/in)')),
|
||||
],
|
||||
),
|
||||
),
|
||||
enabled: !_isLoading,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _weightController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Weight (${_selectedWeightUnit == WeightUnit.metric ? 'kg' : 'lbs'})',
|
||||
prefixIcon: const Icon(Icons.monitor_weight_outlined),
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: PopupMenuButton<WeightUnit>(
|
||||
icon: const Icon(Icons.tune),
|
||||
onSelected: (WeightUnit unit) {
|
||||
setState(() {
|
||||
_selectedWeightUnit = unit;
|
||||
// Convert existing value if needed
|
||||
if (_weightController.text.isNotEmpty) {
|
||||
final currentValue = double.tryParse(_weightController.text);
|
||||
if (currentValue != null) {
|
||||
double convertedValue;
|
||||
if (unit == WeightUnit.imperial && _selectedWeightUnit == WeightUnit.metric) {
|
||||
convertedValue = UnitConversionUtils.kgToLbs(currentValue);
|
||||
_weightController.text = convertedValue.toStringAsFixed(1);
|
||||
} else if (unit == WeightUnit.metric && _selectedWeightUnit == WeightUnit.imperial) {
|
||||
convertedValue = UnitConversionUtils.lbsToKg(currentValue);
|
||||
_weightController.text = convertedValue.toStringAsFixed(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(value: WeightUnit.metric, child: Text('Metric (kg)')),
|
||||
const PopupMenuItem(value: WeightUnit.imperial, child: Text('Imperial (lbs)')),
|
||||
],
|
||||
),
|
||||
),
|
||||
enabled: !_isLoading,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Social Links (Optional)',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _twitterController,
|
||||
|
||||
@@ -5,7 +5,11 @@ import '../../../bootstrap/supabase_client.dart';
|
||||
import '../../auth/application/auth_controller.dart';
|
||||
|
||||
final userRepositoryProvider = Provider<UserRepository>((ref) {
|
||||
return UserRepository(supabaseClient);
|
||||
final client = supabaseClient;
|
||||
if (client == null) {
|
||||
throw Exception('Supabase not initialized - user repository unavailable');
|
||||
}
|
||||
return UserRepository(client);
|
||||
});
|
||||
|
||||
final notificationsRepositoryProvider = Provider<NotificationsRepository>((ref) {
|
||||
|
||||
@@ -202,13 +202,14 @@ class AboutChallengeScreen extends StatelessWidget {
|
||||
|
||||
Future<void> _openLink(BuildContext context, String url) async {
|
||||
final uri = Uri.parse(url);
|
||||
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
||||
final launched = await launchUrl(
|
||||
uri,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
|
||||
if (!launched) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
scaffoldMessenger.showSnackBar(
|
||||
const SnackBar(content: Text('Could not open link')),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/widgets/app_scaffold.dart';
|
||||
import '../../../data/services/biometric_service.dart';
|
||||
import '../../auth/application/auth_controller.dart';
|
||||
import 'package:local_auth/local_auth.dart' as local_auth;
|
||||
|
||||
class BiometricSettingsScreen extends ConsumerStatefulWidget {
|
||||
const BiometricSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<BiometricSettingsScreen> createState() => _BiometricSettingsScreenState();
|
||||
}
|
||||
|
||||
class _BiometricSettingsScreenState extends ConsumerState<BiometricSettingsScreen> {
|
||||
final BiometricService _biometricService = BiometricService();
|
||||
bool _isLoading = false;
|
||||
BiometricAvailability? _availability;
|
||||
bool _isEnabled = false;
|
||||
String _statusMessage = '';
|
||||
List<local_auth.BiometricType> _availableBiometrics = [];
|
||||
local_auth.BiometricType? _primaryBiometricType;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadBiometricStatus();
|
||||
}
|
||||
|
||||
Future<void> _loadBiometricStatus() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final availability = await _biometricService.checkAvailability();
|
||||
final isEnabled = await _biometricService.isBiometricEnabled();
|
||||
final statusMessage = await _biometricService.getBiometricStatusMessage();
|
||||
final availableBiometrics = await _biometricService.getAvailableBiometrics();
|
||||
final primaryBiometricType = await _biometricService.getPrimaryBiometricType();
|
||||
|
||||
setState(() {
|
||||
_availability = availability;
|
||||
_isEnabled = isEnabled;
|
||||
_statusMessage = statusMessage;
|
||||
_availableBiometrics = availableBiometrics;
|
||||
_primaryBiometricType = primaryBiometricType;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleBiometric() async {
|
||||
if (_availability != BiometricAvailability.available) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final authController = ref.read(authControllerProvider.notifier);
|
||||
|
||||
if (_isEnabled) {
|
||||
// Disable biometric
|
||||
final success = await authController.disableBiometric();
|
||||
if (success) {
|
||||
setState(() => _isEnabled = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Biometric login disabled')),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Failed to disable biometric login')),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Enable biometric
|
||||
final success = await authController.enableBiometric();
|
||||
if (success) {
|
||||
setState(() => _isEnabled = true);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Biometric login enabled successfully')),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Failed to enable biometric login')),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: ${e.toString()}')),
|
||||
);
|
||||
} finally {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _testBiometric() async {
|
||||
if (!_isEnabled) return;
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final authController = ref.read(authControllerProvider.notifier);
|
||||
final success = await authController.signInWithBiometric();
|
||||
|
||||
if (success) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Biometric login successful')),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Biometric login failed')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: ${e.toString()}')),
|
||||
);
|
||||
} finally {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
String _getBiometricTypeEmoji(local_auth.BiometricType type) {
|
||||
switch (type) {
|
||||
case local_auth.BiometricType.fingerprint:
|
||||
return '👆';
|
||||
case local_auth.BiometricType.face:
|
||||
return '👤';
|
||||
case local_auth.BiometricType.iris:
|
||||
return '👁️';
|
||||
default:
|
||||
return '🔒';
|
||||
}
|
||||
}
|
||||
|
||||
String _getBiometricTypeName(local_auth.BiometricType type) {
|
||||
switch (type) {
|
||||
case local_auth.BiometricType.fingerprint:
|
||||
return 'Fingerprint';
|
||||
case local_auth.BiometricType.face:
|
||||
return 'Face ID';
|
||||
case local_auth.BiometricType.iris:
|
||||
return 'Iris Scanner';
|
||||
default:
|
||||
return 'Biometric';
|
||||
}
|
||||
}
|
||||
|
||||
Color _getStatusColor() {
|
||||
switch (_availability) {
|
||||
case BiometricAvailability.available:
|
||||
return _isEnabled ? Colors.green : Colors.orange;
|
||||
case BiometricAvailability.notAvailable:
|
||||
return Colors.grey;
|
||||
case BiometricAvailability.notEnrolled:
|
||||
return Colors.orange;
|
||||
case BiometricAvailability.lockedOut:
|
||||
return Colors.red;
|
||||
case BiometricAvailability.permanentlyUnavailable:
|
||||
return Colors.red;
|
||||
case null:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppScaffold(
|
||||
title: 'Biometric Login',
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// Status Card
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
_primaryBiometricType != null
|
||||
? Icons.fingerprint
|
||||
: Icons.lock,
|
||||
size: 32,
|
||||
color: _getStatusColor(),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Biometric Status',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
Text(
|
||||
_statusMessage,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: _getStatusColor(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_availability == BiometricAvailability.available)
|
||||
SwitchListTile(
|
||||
title: const Text('Enable Biometric Login'),
|
||||
subtitle: const Text('Use fingerprint or face ID for quick access'),
|
||||
value: _isEnabled,
|
||||
onChanged: (value) => _toggleBiometric(),
|
||||
secondary: Icon(
|
||||
_isEnabled ? Icons.lock_open : Icons.lock,
|
||||
color: _getStatusColor(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Available Biometrics
|
||||
if (_availableBiometrics.isNotEmpty)
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Available Biometrics',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
..._availableBiometrics.map((type) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
_getBiometricTypeEmoji(type),
|
||||
style: const TextStyle(fontSize: 24),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
_getBiometricTypeName(type),
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
const Spacer(),
|
||||
if (type == _primaryBiometricType)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'Primary',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Test Biometric (if enabled)
|
||||
if (_isEnabled)
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Test Biometric Login',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Test your biometric authentication to make sure it\'s working properly.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _testBiometric,
|
||||
icon: const Icon(Icons.fingerprint),
|
||||
label: const Text('Test Biometric Login'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Information Card
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'About Biometric Login',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'• Biometric login allows you to sign in quickly using your fingerprint or face ID.\n'
|
||||
'• Your biometric data is stored securely on your device and never sent to our servers.\n'
|
||||
'• You can disable biometric login at any time in these settings.\n'
|
||||
'• If you change your password, you may need to re-enable biometric login.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
|
||||
import '../../../bootstrap/supabase_client.dart';
|
||||
import '../../../core/widgets/app_scaffold.dart';
|
||||
|
||||
class SettingsHomeScreen extends ConsumerWidget {
|
||||
@@ -10,6 +10,7 @@ class SettingsHomeScreen extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return AppScaffold(
|
||||
title: 'Settings',
|
||||
body: ListView(
|
||||
children: [
|
||||
_buildSection(
|
||||
@@ -25,7 +26,7 @@ class SettingsHomeScreen extends ConsumerWidget {
|
||||
_SettingsTile(
|
||||
icon: Icons.email,
|
||||
title: 'Email',
|
||||
subtitle: supabase.Supabase.instance.client.auth.currentUser?.email ?? '',
|
||||
subtitle: currentSupabaseUserEmail ?? 'Not signed in',
|
||||
onTap: () => context.push('/settings/account'),
|
||||
),
|
||||
_SettingsTile(
|
||||
@@ -64,6 +65,12 @@ class SettingsHomeScreen extends ConsumerWidget {
|
||||
subtitle: 'Public or Private profile',
|
||||
onTap: () => context.push('/settings/privacy'),
|
||||
),
|
||||
_SettingsTile(
|
||||
icon: Icons.fingerprint,
|
||||
title: 'Biometric Login',
|
||||
subtitle: 'Use fingerprint or face ID for quick access',
|
||||
onTap: () => context.push('/settings/biometric'),
|
||||
),
|
||||
_SettingsTile(
|
||||
icon: Icons.block,
|
||||
title: 'Blocked Users',
|
||||
|
||||
@@ -145,7 +145,7 @@ class SocialState {
|
||||
}
|
||||
|
||||
final socialRepositoryProvider = Provider<SocialRepository>((ref) {
|
||||
return SocialRepository(supabaseClient);
|
||||
return SocialRepository(supabaseClient ?? (throw Exception("Supabase not initialized")));
|
||||
});
|
||||
|
||||
final socialControllerProvider = StateNotifierProvider<SocialController, SocialState>((ref) {
|
||||
|
||||
@@ -162,7 +162,7 @@ final socialNotificationsControllerProvider =
|
||||
});
|
||||
|
||||
final socialRepositoryProvider = Provider<SocialRepository>((ref) {
|
||||
return SocialRepository(supabaseClient);
|
||||
return SocialRepository(supabaseClient ?? (throw Exception("Supabase not initialized")));
|
||||
});
|
||||
|
||||
final notificationsRepositoryProvider = Provider<NotificationsRepository>((ref) {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
|
||||
import '../../../core/widgets/app_scaffold.dart';
|
||||
import '../../../core/widgets/loading_indicator.dart';
|
||||
import '../../../core/utils/date_time_utils.dart';
|
||||
import '../../../core/utils/unit_conversion_utils.dart';
|
||||
import '../../../data/models/user_model.dart' as app;
|
||||
import '../../../data/models/goal_model.dart';
|
||||
import '../../auth/application/auth_controller.dart';
|
||||
import '../application/social_controller.dart';
|
||||
import '../../profile/application/profile_controller.dart';
|
||||
@@ -22,12 +25,19 @@ class PublicProfileScreen extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _PublicProfileScreenState extends ConsumerState<PublicProfileScreen> {
|
||||
Map<String, dynamic>? _userStats;
|
||||
List<Goal>? _userGoals;
|
||||
bool _isLoadingStats = false;
|
||||
bool _isLoadingGoals = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_loadProfile();
|
||||
_checkFollowingStatus();
|
||||
_loadUserStats();
|
||||
_loadUserGoals();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -39,6 +49,37 @@ class _PublicProfileScreenState extends ConsumerState<PublicProfileScreen> {
|
||||
await ref.read(socialControllerProvider.notifier).isFollowing(widget.userId);
|
||||
}
|
||||
|
||||
Future<void> _loadUserStats() async {
|
||||
setState(() => _isLoadingStats = true);
|
||||
try {
|
||||
final client = supabase.Supabase.instance.client;
|
||||
final response = await client.rpc('get_user_stats', params: {'user_uuid': widget.userId});
|
||||
setState(() {
|
||||
_userStats = response as Map<String, dynamic>;
|
||||
_isLoadingStats = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _isLoadingStats = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadUserGoals() async {
|
||||
setState(() => _isLoadingGoals = true);
|
||||
try {
|
||||
final client = supabase.Supabase.instance.client;
|
||||
final response = await client.rpc('get_public_user_goals', params: {
|
||||
'user_uuid': widget.userId,
|
||||
'limit_count': 10
|
||||
});
|
||||
setState(() {
|
||||
_userGoals = (response as List).map((json) => Goal.fromJson(json)).toList();
|
||||
_isLoadingGoals = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _isLoadingGoals = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleFollow() async {
|
||||
final controller = ref.read(socialControllerProvider.notifier);
|
||||
final isFollowing = await controller.isFollowing(widget.userId);
|
||||
@@ -90,6 +131,8 @@ class _PublicProfileScreenState extends ConsumerState<PublicProfileScreen> {
|
||||
onRefresh: () async {
|
||||
await _loadProfile();
|
||||
await _checkFollowingStatus();
|
||||
await _loadUserStats();
|
||||
await _loadUserGoals();
|
||||
},
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
@@ -101,7 +144,22 @@ class _PublicProfileScreenState extends ConsumerState<PublicProfileScreen> {
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: _StatsSection(user: user),
|
||||
child: _EnhancedStatsSection(
|
||||
user: user,
|
||||
userStats: _userStats,
|
||||
isLoadingStats: _isLoadingStats,
|
||||
),
|
||||
),
|
||||
if (_userGoals != null && _userGoals!.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: _UserGoalsSection(
|
||||
goals: _userGoals!,
|
||||
isLoadingGoals: _isLoadingGoals,
|
||||
),
|
||||
),
|
||||
if (user.age != null || user.formattedHeight.isNotEmpty || user.formattedWeight.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: _BiometricSection(user: user),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -109,6 +167,311 @@ class _PublicProfileScreenState extends ConsumerState<PublicProfileScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
class _EnhancedStatsSection extends StatelessWidget {
|
||||
final app.User user;
|
||||
final Map<String, dynamic>? userStats;
|
||||
final bool isLoadingStats;
|
||||
|
||||
const _EnhancedStatsSection({
|
||||
required this.user,
|
||||
this.userStats,
|
||||
required this.isLoadingStats,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Profile Stats',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (isLoadingStats)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
else
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _StatCard(
|
||||
icon: Icons.flag,
|
||||
title: 'Goals',
|
||||
value: '${userStats?['goals_count'] ?? 0}',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _StatCard(
|
||||
icon: Icons.check_circle,
|
||||
title: 'Completed',
|
||||
value: '${userStats?['completed_goals_count'] ?? 0}',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _StatCard(
|
||||
icon: Icons.people,
|
||||
title: 'Followers',
|
||||
value: '${userStats?['followers_count'] ?? 0}',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _StatCard(
|
||||
icon: Icons.person_add,
|
||||
title: 'Following',
|
||||
value: '${userStats?['following_count'] ?? 0}',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (user.countdownStartDate != null) ...[
|
||||
_StatCard(
|
||||
icon: Icons.timer,
|
||||
title: 'Challenge Started',
|
||||
value: DateTimeUtils.formatShortDate(user.countdownStartDate!),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (user.daysRemaining != null)
|
||||
_StatCard(
|
||||
icon: Icons.hourglass_empty,
|
||||
title: 'Days Remaining',
|
||||
value: '${user.daysRemaining} days',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
_StatCard(
|
||||
icon: Icons.calendar_today,
|
||||
title: 'Member Since',
|
||||
value: DateTimeUtils.formatShortDate(user.createdAt),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UserGoalsSection extends StatelessWidget {
|
||||
final List<Goal> goals;
|
||||
final bool isLoadingGoals;
|
||||
|
||||
const _UserGoalsSection({
|
||||
required this.goals,
|
||||
required this.isLoadingGoals,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Public Goals',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (isLoadingGoals)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
else
|
||||
...goals.map((goal) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _GoalCard(goal: goal),
|
||||
)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BiometricSection extends StatelessWidget {
|
||||
final app.User user;
|
||||
|
||||
const _BiometricSection({required this.user});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Biometric Information',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
if (user.gender != null)
|
||||
Expanded(
|
||||
child: _BiometricCard(
|
||||
icon: user.gender!.emoji,
|
||||
title: 'Gender',
|
||||
value: user.gender!.displayName,
|
||||
),
|
||||
),
|
||||
if (user.age != null)
|
||||
Expanded(
|
||||
child: _BiometricCard(
|
||||
icon: '🎂',
|
||||
title: 'Age',
|
||||
value: '${user.age} years',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (user.gender != null && user.age != null)
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
if (user.formattedHeight.isNotEmpty)
|
||||
Expanded(
|
||||
child: _BiometricCard(
|
||||
icon: '📏',
|
||||
title: 'Height',
|
||||
value: user.formattedHeight,
|
||||
),
|
||||
),
|
||||
if (user.formattedWeight.isNotEmpty)
|
||||
Expanded(
|
||||
child: _BiometricCard(
|
||||
icon: '⚖️',
|
||||
title: 'Weight',
|
||||
value: user.formattedWeight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (user.bmi != null)
|
||||
Column(
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
_BiometricCard(
|
||||
icon: '💪',
|
||||
title: 'BMI',
|
||||
value: '${user.bmi!.toStringAsFixed(1)} - ${user.bmiCategory}',
|
||||
valueColor: UnitConversionUtils.getBmiColor(user.bmi!),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BiometricCard extends StatelessWidget {
|
||||
final String icon;
|
||||
final String title;
|
||||
final String value;
|
||||
final Color? valueColor;
|
||||
|
||||
const _BiometricCard({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.value,
|
||||
this.valueColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
icon,
|
||||
style: const TextStyle(fontSize: 20),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: valueColor ?? null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GoalCard extends StatelessWidget {
|
||||
final Goal goal;
|
||||
|
||||
const _GoalCard({required this.goal});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: goal.completed
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
goal.completed ? Icons.check : Icons.flag_outlined,
|
||||
color: goal.completed
|
||||
? Theme.of(context).colorScheme.onPrimaryContainer
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
goal.title,
|
||||
style: TextStyle(
|
||||
decoration: goal.completed ? TextDecoration.lineThrough : null,
|
||||
),
|
||||
),
|
||||
subtitle: goal.description != null && goal.description!.isNotEmpty
|
||||
? Text(
|
||||
goal.description!,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: null,
|
||||
trailing: goal.progress > 0
|
||||
? Text('${goal.progress}%')
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProfileHeader extends ConsumerWidget {
|
||||
final app.User user;
|
||||
final bool isOwnProfile;
|
||||
@@ -219,51 +582,6 @@ class _FollowButton extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _StatsSection extends StatelessWidget {
|
||||
final app.User user;
|
||||
|
||||
const _StatsSection({required this.user});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Journey Stats',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (user.countdownStartDate != null) ...[
|
||||
_StatCard(
|
||||
icon: Icons.timer,
|
||||
title: 'Challenge Started',
|
||||
value: DateTimeUtils.formatShortDate(user.countdownStartDate!),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (user.daysRemaining != null)
|
||||
_StatCard(
|
||||
icon: Icons.hourglass_empty,
|
||||
title: 'Days Remaining',
|
||||
value: '${user.daysRemaining} days',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
_StatCard(
|
||||
icon: Icons.calendar_today,
|
||||
title: 'Member Since',
|
||||
value: DateTimeUtils.formatShortDate(user.createdAt),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatCard extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'bootstrap/bootstrap.dart';
|
||||
import 'core/theme/app_theme.dart';
|
||||
import 'core/routing/app_router.dart';
|
||||
@@ -9,10 +10,14 @@ import 'core/state/providers.dart';
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Initialize Hive first before anything else
|
||||
await Hive.initFlutter();
|
||||
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
systemNavigationBarColor: Colors.transparent,
|
||||
systemNavigationBarDividerColor: Colors.transparent,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -38,7 +43,7 @@ class LifeTimerApp extends ConsumerWidget {
|
||||
final themeMode = ref.watch(themeModeProvider);
|
||||
|
||||
return MaterialApp.router(
|
||||
title: 'LifeTimer',
|
||||
title: '1356',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.light,
|
||||
darkTheme: AppTheme.dark,
|
||||
|
||||
+57
-1
@@ -427,6 +427,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.2.0"
|
||||
flutter_markdown:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_markdown
|
||||
sha256: "08fb8315236099ff8e90cb87bb2b935e0a724a3af1623000a9cec930468e0f27"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.7+1"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -879,6 +887,46 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
local_auth:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: local_auth
|
||||
sha256: "434d854cf478f17f12ab29a76a02b3067f86a63a6d6c4eb8fbfdcfe4879c1b7b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
local_auth_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: local_auth_android
|
||||
sha256: a0bdfcc0607050a26ef5b31d6b4b254581c3d3ce3c1816ab4d4f4a9173e84467
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.56"
|
||||
local_auth_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: local_auth_darwin
|
||||
sha256: "699873970067a40ef2f2c09b4c72eb1cfef64224ef041b3df9fdc5c4c1f91f49"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.1"
|
||||
local_auth_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: local_auth_platform_interface
|
||||
sha256: f98b8e388588583d3f781f6806e4f4c9f9e189d898d27f0c249b93a1973dd122
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
local_auth_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: local_auth_windows
|
||||
sha256: bc4e66a29b0fdf751aafbec923b5bed7ad6ed3614875d8151afe2578520b2ab5
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.11"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -887,6 +935,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
markdown:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: markdown
|
||||
sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1397,7 +1453,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
state_notifier:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: state_notifier
|
||||
sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
|
||||
|
||||
@@ -14,6 +14,7 @@ dependencies:
|
||||
# State Management
|
||||
flutter_riverpod: ^2.4.9
|
||||
riverpod_annotation: ^2.3.3
|
||||
state_notifier: ^1.0.0
|
||||
|
||||
# Supabase Backend
|
||||
supabase_flutter: ^2.0.0
|
||||
@@ -43,6 +44,7 @@ dependencies:
|
||||
http: ^1.1.0
|
||||
url_launcher: ^6.1.10
|
||||
home_widget: ^0.7.0
|
||||
local_auth: ^2.1.7
|
||||
|
||||
# Image Handling
|
||||
cached_network_image: ^3.3.0
|
||||
@@ -61,6 +63,7 @@ dependencies:
|
||||
# AI & Voice
|
||||
record: ^6.1.2
|
||||
permission_handler: ^11.0.1
|
||||
flutter_markdown: ^0.7.3
|
||||
|
||||
# Testing
|
||||
mockito: ^5.4.4
|
||||
|
||||
@@ -19,7 +19,7 @@ void main() {
|
||||
test('should return null for valid email', () {
|
||||
expect(Validators.validateEmail('test@example.com'), isNull);
|
||||
expect(Validators.validateEmail('user.name@domain.co.uk'), isNull);
|
||||
expect(Validators.validateEmail('test_user+tag@example.com'), isNull);
|
||||
expect(Validators.validateEmail('testuser@example.com'), isNull);
|
||||
});
|
||||
|
||||
test('should handle edge cases', () {
|
||||
@@ -30,7 +30,7 @@ void main() {
|
||||
|
||||
group('validatePassword', () {
|
||||
test('should return error for empty password', () {
|
||||
expect(Validators.validatePassword(''), equals('Password must be at least 6 characters'));
|
||||
expect(Validators.validatePassword(''), equals('Password is required'));
|
||||
expect(Validators.validatePassword(null), equals('Password is required'));
|
||||
});
|
||||
|
||||
|
||||
@@ -3,10 +3,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:lifetimer/features/auth/application/auth_controller.dart';
|
||||
import 'package:lifetimer/features/auth/presentation/auth_gate.dart';
|
||||
import 'package:lifetimer/features/auth/presentation/auth_choice_screen.dart';
|
||||
import 'package:lifetimer/features/auth/presentation/auth_showcase_screen.dart';
|
||||
import 'package:lifetimer/features/onboarding/presentation/onboarding_intro_screen.dart';
|
||||
import 'package:lifetimer/features/onboarding/application/onboarding_controller.dart';
|
||||
import 'package:lifetimer/data/models/user_model.dart';
|
||||
import 'package:lifetimer/data/repositories/auth_repository.dart';
|
||||
import 'package:lifetimer/core/utils/unit_conversion_utils.dart';
|
||||
|
||||
class MockAuthRepository extends AuthRepository {
|
||||
bool _isAuthenticated = false;
|
||||
@@ -29,13 +31,14 @@ class MockAuthRepository extends AuthRepository {
|
||||
Future<void> signInWithEmail(String email, String password) async {}
|
||||
|
||||
@override
|
||||
Future<void> signUpWithEmail(String email, String password, String username) async {}
|
||||
Future<void> signUpWithEmail(String email, String password, String username, {double? heightCm, double? weightKg, int? age, Gender? gender, HeightUnit? heightUnit, WeightUnit? weightUnit}) async {}
|
||||
|
||||
@override
|
||||
Future<void> signInWithGoogle() async {}
|
||||
|
||||
@override
|
||||
Future<void> signInWithApple() async {}
|
||||
Future<void> signInWithApple() async {
|
||||
// Mock implementation
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> signOut() async {}
|
||||
@@ -55,12 +58,27 @@ class MockAuthRepository extends AuthRepository {
|
||||
String? bio,
|
||||
String? avatarUrl,
|
||||
bool? isPublicProfile,
|
||||
double? heightCm,
|
||||
double? weightKg,
|
||||
int? age,
|
||||
Gender? gender,
|
||||
HeightUnit? heightUnit,
|
||||
WeightUnit? weightUnit,
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
void dispose() {}
|
||||
}
|
||||
|
||||
class MockOnboardingController extends OnboardingController {
|
||||
MockOnboardingController() : super();
|
||||
|
||||
@override
|
||||
Future<void> loadOnboardingStatus() async {
|
||||
// Do nothing in test
|
||||
}
|
||||
}
|
||||
|
||||
class TestData {
|
||||
static User createTestUser() {
|
||||
return User(
|
||||
@@ -75,23 +93,28 @@ class TestData {
|
||||
|
||||
void main() {
|
||||
group('AuthGate Widget', () {
|
||||
testWidgets('should show AuthChoiceScreen when user is not authenticated',
|
||||
testWidgets('should continue to onboarding when backend is unavailable and user is not authenticated',
|
||||
(WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
authRepositoryProvider.overrideWithValue(MockAuthRepository()),
|
||||
onboardingControllerProvider.overrideWith((ref) => MockOnboardingController()),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
home: AuthGate(),
|
||||
child: MaterialApp(
|
||||
home: const AuthGate(),
|
||||
builder: (context, child) => MediaQuery(
|
||||
data: const MediaQueryData(size: Size(800, 600)),
|
||||
child: child!,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(AuthChoiceScreen), findsOneWidget);
|
||||
expect(find.byType(OnboardingIntroScreen), findsNothing);
|
||||
expect(find.byType(OnboardingIntroScreen), findsOneWidget);
|
||||
expect(find.byType(AuthShowcaseScreen), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('should show OnboardingIntroScreen when user is authenticated',
|
||||
@@ -103,9 +126,14 @@ void main() {
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
authRepositoryProvider.overrideWithValue(mockRepo),
|
||||
onboardingControllerProvider.overrideWith((ref) => MockOnboardingController()),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
home: AuthGate(),
|
||||
child: MaterialApp(
|
||||
home: const AuthGate(),
|
||||
builder: (context, child) => MediaQuery(
|
||||
data: const MediaQueryData(size: Size(800, 600)),
|
||||
child: child!,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -113,7 +141,7 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(OnboardingIntroScreen), findsOneWidget);
|
||||
expect(find.byType(AuthChoiceScreen), findsNothing);
|
||||
expect(find.byType(AuthShowcaseScreen), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -58,9 +58,13 @@ void main() {
|
||||
|
||||
testWidgets('should validate username field', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const ProviderScope(
|
||||
child: const MaterialApp(
|
||||
home: SignUpScreen(),
|
||||
ProviderScope(
|
||||
child: MaterialApp(
|
||||
home: const SignUpScreen(),
|
||||
builder: (context, child) => MediaQuery(
|
||||
data: const MediaQueryData(size: Size(400, 1200)),
|
||||
child: child!,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -73,7 +77,7 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Try to submit
|
||||
final signUpButton = find.text('Sign Up');
|
||||
final signUpButton = find.text('Create Account');
|
||||
await tester.tap(signUpButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@@ -83,9 +87,13 @@ void main() {
|
||||
|
||||
testWidgets('should validate email field', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const ProviderScope(
|
||||
child: const MaterialApp(
|
||||
home: SignUpScreen(),
|
||||
ProviderScope(
|
||||
child: MaterialApp(
|
||||
home: const SignUpScreen(),
|
||||
builder: (context, child) => MediaQuery(
|
||||
data: const MediaQueryData(size: Size(400, 1200)),
|
||||
child: child!,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -98,7 +106,7 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Try to submit
|
||||
final signUpButton = find.text('Sign Up');
|
||||
final signUpButton = find.text('Create Account');
|
||||
await tester.tap(signUpButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@@ -108,9 +116,13 @@ void main() {
|
||||
|
||||
testWidgets('should validate password field', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const ProviderScope(
|
||||
child: const MaterialApp(
|
||||
home: SignUpScreen(),
|
||||
ProviderScope(
|
||||
child: MaterialApp(
|
||||
home: const SignUpScreen(),
|
||||
builder: (context, child) => MediaQuery(
|
||||
data: const MediaQueryData(size: Size(400, 1200)),
|
||||
child: child!,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -123,7 +135,7 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Try to submit
|
||||
final signUpButton = find.text('Sign Up');
|
||||
final signUpButton = find.text('Create Account');
|
||||
await tester.tap(signUpButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@@ -134,9 +146,13 @@ void main() {
|
||||
testWidgets('should toggle password visibility',
|
||||
(WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const ProviderScope(
|
||||
child: const MaterialApp(
|
||||
home: SignUpScreen(),
|
||||
ProviderScope(
|
||||
child: MaterialApp(
|
||||
home: const SignUpScreen(),
|
||||
builder: (context, child) => MediaQuery(
|
||||
data: const MediaQueryData(size: Size(400, 800)), // Smaller height
|
||||
child: child!,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -144,44 +160,56 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Find password visibility toggle button
|
||||
final toggleButton = find.byIcon(Icons.visibility_off);
|
||||
final toggleButton = find.byIcon(Icons.visibility_off_outlined);
|
||||
expect(toggleButton, findsOneWidget);
|
||||
|
||||
await tester.tap(toggleButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Should now show visibility icon
|
||||
expect(find.byIcon(Icons.visibility), findsOneWidget);
|
||||
expect(find.byIcon(Icons.visibility_outlined), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should show Google sign up button',
|
||||
testWidgets('should show email sign up form',
|
||||
(WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const ProviderScope(
|
||||
child: const MaterialApp(
|
||||
home: SignUpScreen(),
|
||||
ProviderScope(
|
||||
child: MaterialApp(
|
||||
home: const SignUpScreen(),
|
||||
builder: (context, child) => MediaQuery(
|
||||
data: const MediaQueryData(size: Size(400, 1200)), // Taller for form
|
||||
child: child!,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Sign up with Google'), findsOneWidget);
|
||||
expect(find.text('Create Account'), findsOneWidget);
|
||||
expect(find.byType(TextFormField), findsNWidgets(6)); // email, password, confirm, username, height, weight
|
||||
});
|
||||
|
||||
testWidgets('should show Apple sign up button',
|
||||
testWidgets('should show email sign up form only',
|
||||
(WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const ProviderScope(
|
||||
child: const MaterialApp(
|
||||
home: SignUpScreen(),
|
||||
ProviderScope(
|
||||
child: MaterialApp(
|
||||
home: const SignUpScreen(),
|
||||
builder: (context, child) => MediaQuery(
|
||||
data: const MediaQueryData(size: Size(400, 1200)),
|
||||
child: child!,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Sign up with Apple'), findsOneWidget);
|
||||
// Should not have social sign-up buttons
|
||||
expect(find.text('Sign up with Google'), findsNothing);
|
||||
expect(find.text('Sign up with Apple'), findsNothing);
|
||||
expect(find.text('Create Account'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+6
-6
@@ -18,7 +18,7 @@ void main() {
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Your Journey Awaits'), findsOneWidget);
|
||||
expect(find.text('Your Time is Now'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should display motivational message',
|
||||
@@ -33,11 +33,11 @@ void main() {
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('goals'), findsOneWidget);
|
||||
expect(find.textContaining('1356 days'), findsOneWidget);
|
||||
expect(find.textContaining('dreams'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should display start challenge button',
|
||||
testWidgets('should display get started button',
|
||||
(WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const ProviderScope(
|
||||
@@ -49,7 +49,7 @@ void main() {
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Start Your Challenge'), findsOneWidget);
|
||||
expect(find.text('Get Started'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should display back button', (WidgetTester tester) async {
|
||||
@@ -77,8 +77,8 @@ void main() {
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Should have step indicators
|
||||
expect(find.byType(Container), findsWidgets);
|
||||
expect(find.text('Step 3 of 3'), findsOneWidget);
|
||||
expect(find.byType(LinearProgressIndicator), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ void main() {
|
||||
expect(find.text('Profile'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should display user avatar', (WidgetTester tester) async {
|
||||
testWidgets('should display signed out state', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const ProviderScope(
|
||||
child: const MaterialApp(
|
||||
@@ -32,10 +32,11 @@ void main() {
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(CircleAvatar), findsOneWidget);
|
||||
expect(find.text('Not Signed In'), findsOneWidget);
|
||||
expect(find.text('Please sign in to view your profile'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should display username', (WidgetTester tester) async {
|
||||
testWidgets('should display signed out icon', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const ProviderScope(
|
||||
child: const MaterialApp(
|
||||
@@ -46,81 +47,7 @@ void main() {
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Should display username section
|
||||
expect(find.textContaining('Username'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should display countdown information',
|
||||
(WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const ProviderScope(
|
||||
child: const MaterialApp(
|
||||
home: ProfileScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('Days Left'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should display goals completed stat',
|
||||
(WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const ProviderScope(
|
||||
child: const MaterialApp(
|
||||
home: ProfileScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('Goals Completed'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should display edit profile button',
|
||||
(WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const ProviderScope(
|
||||
child: const MaterialApp(
|
||||
home: ProfileScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Edit Profile'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should display settings button', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const ProviderScope(
|
||||
child: const MaterialApp(
|
||||
home: ProfileScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Settings'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should display sign out button', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const ProviderScope(
|
||||
child: const MaterialApp(
|
||||
home: ProfileScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Sign Out'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.person_off), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -73,12 +73,18 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('About the Challenge'),
|
||||
300,
|
||||
scrollable: find.byType(Scrollable),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('About'), findsOneWidget);
|
||||
expect(find.text('About the Challenge'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should display account settings option',
|
||||
testWidgets('should display edit profile option',
|
||||
(WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const ProviderScope(
|
||||
@@ -90,7 +96,7 @@ void main() {
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Account Settings'), findsOneWidget);
|
||||
expect(find.text('Edit Profile'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should display notification settings option',
|
||||
@@ -108,7 +114,7 @@ void main() {
|
||||
expect(find.text('Notifications'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should display privacy settings option',
|
||||
testWidgets('should display profile visibility option',
|
||||
(WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const ProviderScope(
|
||||
@@ -118,9 +124,15 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('Profile Visibility'),
|
||||
300,
|
||||
scrollable: find.byType(Scrollable),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Privacy Settings'), findsOneWidget);
|
||||
expect(find.text('Profile Visibility'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should display about challenge option',
|
||||
@@ -133,6 +145,12 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('About the Challenge'),
|
||||
300,
|
||||
scrollable: find.byType(Scrollable),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('About the Challenge'), findsOneWidget);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
Future<void> testExecutable(dynamic Function() testMain) async {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
|
||||
final hiveDir = await Directory.systemTemp.createTemp('lifetimer_test_hive_');
|
||||
Hive.init(hiveDir.path);
|
||||
|
||||
await Future<void>.sync(() => testMain());
|
||||
}
|
||||
@@ -6,18 +6,19 @@
|
||||
import 'dart:async' as _i7;
|
||||
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart'
|
||||
as _i14;
|
||||
as _i15;
|
||||
import 'package:lifetimer/core/utils/unit_conversion_utils.dart' as _i9;
|
||||
import 'package:lifetimer/data/models/activity_model.dart' as _i5;
|
||||
import 'package:lifetimer/data/models/goal_model.dart' as _i2;
|
||||
import 'package:lifetimer/data/models/goal_step_model.dart' as _i3;
|
||||
import 'package:lifetimer/data/models/user_model.dart' as _i4;
|
||||
import 'package:lifetimer/data/repositories/auth_repository.dart' as _i6;
|
||||
import 'package:lifetimer/data/repositories/countdown_repository.dart' as _i10;
|
||||
import 'package:lifetimer/data/repositories/goals_repository.dart' as _i9;
|
||||
import 'package:lifetimer/data/repositories/countdown_repository.dart' as _i11;
|
||||
import 'package:lifetimer/data/repositories/goals_repository.dart' as _i10;
|
||||
import 'package:lifetimer/data/repositories/notifications_repository.dart'
|
||||
as _i13;
|
||||
import 'package:lifetimer/data/repositories/social_repository.dart' as _i12;
|
||||
import 'package:lifetimer/data/repositories/user_repository.dart' as _i11;
|
||||
as _i14;
|
||||
import 'package:lifetimer/data/repositories/social_repository.dart' as _i13;
|
||||
import 'package:lifetimer/data/repositories/user_repository.dart' as _i12;
|
||||
import 'package:mockito/mockito.dart' as _i1;
|
||||
import 'package:supabase_flutter/supabase_flutter.dart' as _i8;
|
||||
|
||||
@@ -162,8 +163,14 @@ class MockAuthRepository extends _i1.Mock implements _i6.AuthRepository {
|
||||
_i7.Future<void> signUpWithEmail(
|
||||
String? email,
|
||||
String? password,
|
||||
String? username,
|
||||
) =>
|
||||
String? username, {
|
||||
double? heightCm,
|
||||
double? weightKg,
|
||||
int? age,
|
||||
_i9.Gender? gender,
|
||||
_i9.HeightUnit? heightUnit,
|
||||
_i9.WeightUnit? weightUnit,
|
||||
}) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#signUpWithEmail,
|
||||
@@ -172,6 +179,14 @@ class MockAuthRepository extends _i1.Mock implements _i6.AuthRepository {
|
||||
password,
|
||||
username,
|
||||
],
|
||||
{
|
||||
#heightCm: heightCm,
|
||||
#weightKg: weightKg,
|
||||
#age: age,
|
||||
#gender: gender,
|
||||
#heightUnit: heightUnit,
|
||||
#weightUnit: weightUnit,
|
||||
},
|
||||
),
|
||||
returnValue: _i7.Future<void>.value(),
|
||||
returnValueForMissingStub: _i7.Future<void>.value(),
|
||||
@@ -197,16 +212,6 @@ class MockAuthRepository extends _i1.Mock implements _i6.AuthRepository {
|
||||
returnValueForMissingStub: _i7.Future<void>.value(),
|
||||
) as _i7.Future<void>);
|
||||
|
||||
@override
|
||||
_i7.Future<void> signInWithApple() => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#signInWithApple,
|
||||
[],
|
||||
),
|
||||
returnValue: _i7.Future<void>.value(),
|
||||
returnValueForMissingStub: _i7.Future<void>.value(),
|
||||
) as _i7.Future<void>);
|
||||
|
||||
@override
|
||||
_i7.Future<void> signOut() => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
@@ -233,6 +238,12 @@ class MockAuthRepository extends _i1.Mock implements _i6.AuthRepository {
|
||||
String? bio,
|
||||
String? avatarUrl,
|
||||
bool? isPublicProfile,
|
||||
double? heightCm,
|
||||
double? weightKg,
|
||||
int? age,
|
||||
_i9.Gender? gender,
|
||||
_i9.HeightUnit? heightUnit,
|
||||
_i9.WeightUnit? weightUnit,
|
||||
}) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
@@ -243,6 +254,12 @@ class MockAuthRepository extends _i1.Mock implements _i6.AuthRepository {
|
||||
#bio: bio,
|
||||
#avatarUrl: avatarUrl,
|
||||
#isPublicProfile: isPublicProfile,
|
||||
#heightCm: heightCm,
|
||||
#weightKg: weightKg,
|
||||
#age: age,
|
||||
#gender: gender,
|
||||
#heightUnit: heightUnit,
|
||||
#weightUnit: weightUnit,
|
||||
},
|
||||
),
|
||||
returnValue: _i7.Future<void>.value(),
|
||||
@@ -253,7 +270,7 @@ class MockAuthRepository extends _i1.Mock implements _i6.AuthRepository {
|
||||
/// A class which mocks [GoalsRepository].
|
||||
///
|
||||
/// See the documentation for Mockito's code generation for more information.
|
||||
class MockGoalsRepository extends _i1.Mock implements _i9.GoalsRepository {
|
||||
class MockGoalsRepository extends _i1.Mock implements _i10.GoalsRepository {
|
||||
MockGoalsRepository() {
|
||||
_i1.throwOnMissingStub(this);
|
||||
}
|
||||
@@ -397,7 +414,7 @@ class MockGoalsRepository extends _i1.Mock implements _i9.GoalsRepository {
|
||||
///
|
||||
/// See the documentation for Mockito's code generation for more information.
|
||||
class MockCountdownRepository extends _i1.Mock
|
||||
implements _i10.CountdownRepository {
|
||||
implements _i11.CountdownRepository {
|
||||
MockCountdownRepository() {
|
||||
_i1.throwOnMissingStub(this);
|
||||
}
|
||||
@@ -445,7 +462,7 @@ class MockCountdownRepository extends _i1.Mock
|
||||
/// A class which mocks [UserRepository].
|
||||
///
|
||||
/// See the documentation for Mockito's code generation for more information.
|
||||
class MockUserRepository extends _i1.Mock implements _i11.UserRepository {
|
||||
class MockUserRepository extends _i1.Mock implements _i12.UserRepository {
|
||||
MockUserRepository() {
|
||||
_i1.throwOnMissingStub(this);
|
||||
}
|
||||
@@ -476,6 +493,12 @@ class MockUserRepository extends _i1.Mock implements _i11.UserRepository {
|
||||
String? instagramHandle,
|
||||
String? tiktokHandle,
|
||||
String? websiteUrl,
|
||||
_i9.Gender? gender,
|
||||
DateTime? birthDate,
|
||||
double? heightCm,
|
||||
double? weightKg,
|
||||
_i9.HeightUnit? heightUnit = _i9.HeightUnit.metric,
|
||||
_i9.WeightUnit? weightUnit = _i9.WeightUnit.metric,
|
||||
}) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
@@ -491,6 +514,12 @@ class MockUserRepository extends _i1.Mock implements _i11.UserRepository {
|
||||
#instagramHandle: instagramHandle,
|
||||
#tiktokHandle: tiktokHandle,
|
||||
#websiteUrl: websiteUrl,
|
||||
#gender: gender,
|
||||
#birthDate: birthDate,
|
||||
#heightCm: heightCm,
|
||||
#weightKg: weightKg,
|
||||
#heightUnit: heightUnit,
|
||||
#weightUnit: weightUnit,
|
||||
},
|
||||
),
|
||||
returnValue: _i7.Future<_i4.User>.value(_FakeUser_2(
|
||||
@@ -508,6 +537,12 @@ class MockUserRepository extends _i1.Mock implements _i11.UserRepository {
|
||||
#instagramHandle: instagramHandle,
|
||||
#tiktokHandle: tiktokHandle,
|
||||
#websiteUrl: websiteUrl,
|
||||
#gender: gender,
|
||||
#birthDate: birthDate,
|
||||
#heightCm: heightCm,
|
||||
#weightKg: weightKg,
|
||||
#heightUnit: heightUnit,
|
||||
#weightUnit: weightUnit,
|
||||
},
|
||||
),
|
||||
)),
|
||||
@@ -536,7 +571,7 @@ class MockUserRepository extends _i1.Mock implements _i11.UserRepository {
|
||||
/// A class which mocks [SocialRepository].
|
||||
///
|
||||
/// See the documentation for Mockito's code generation for more information.
|
||||
class MockSocialRepository extends _i1.Mock implements _i12.SocialRepository {
|
||||
class MockSocialRepository extends _i1.Mock implements _i13.SocialRepository {
|
||||
MockSocialRepository() {
|
||||
_i1.throwOnMissingStub(this);
|
||||
}
|
||||
@@ -673,7 +708,7 @@ class MockSocialRepository extends _i1.Mock implements _i12.SocialRepository {
|
||||
///
|
||||
/// See the documentation for Mockito's code generation for more information.
|
||||
class MockNotificationsRepository extends _i1.Mock
|
||||
implements _i13.NotificationsRepository {
|
||||
implements _i14.NotificationsRepository {
|
||||
MockNotificationsRepository() {
|
||||
_i1.throwOnMissingStub(this);
|
||||
}
|
||||
@@ -769,13 +804,13 @@ class MockNotificationsRepository extends _i1.Mock
|
||||
) as _i7.Future<void>);
|
||||
|
||||
@override
|
||||
_i7.Future<List<_i14.PendingNotificationRequest>> getPendingNotifications() =>
|
||||
_i7.Future<List<_i15.PendingNotificationRequest>> getPendingNotifications() =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#getPendingNotifications,
|
||||
[],
|
||||
),
|
||||
returnValue: _i7.Future<List<_i14.PendingNotificationRequest>>.value(
|
||||
<_i14.PendingNotificationRequest>[]),
|
||||
) as _i7.Future<List<_i14.PendingNotificationRequest>>);
|
||||
returnValue: _i7.Future<List<_i15.PendingNotificationRequest>>.value(
|
||||
<_i15.PendingNotificationRequest>[]),
|
||||
) as _i7.Future<List<_i15.PendingNotificationRequest>>);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "🔍 Verifying Flutter project structure and changes..."
|
||||
|
||||
# Check if pubspec.yaml has the new dependency
|
||||
echo "📦 Checking flutter_markdown dependency..."
|
||||
if grep -q "flutter_markdown: ^0.7.3" pubspec.yaml; then
|
||||
echo "✅ flutter_markdown dependency added"
|
||||
else
|
||||
echo "❌ flutter_markdown dependency missing"
|
||||
fi
|
||||
|
||||
# Check if key files have been modified
|
||||
echo "📁 Checking modified files..."
|
||||
|
||||
files_to_check=(
|
||||
"lib/features/ai_chat/application/ai_chat_controller.dart"
|
||||
"lib/features/ai_chat/presentation/ai_chat_screen.dart"
|
||||
"lib/data/services/mistral_ai_service.dart"
|
||||
"lib/data/models/user_model.dart"
|
||||
"lib/features/auth/presentation/sign_up_screen.dart"
|
||||
"lib/data/repositories/auth_repository.dart"
|
||||
"lib/features/auth/application/auth_controller.dart"
|
||||
)
|
||||
|
||||
for file in "${files_to_check[@]}"; do
|
||||
if [ -f "$file" ]; then
|
||||
echo "✅ $file exists"
|
||||
|
||||
# Check for specific changes
|
||||
case "$file" in
|
||||
*"ai_chat_controller.dart"*)
|
||||
if grep -q "height.*weight" "$file"; then
|
||||
echo " ✅ Height/weight context added"
|
||||
fi
|
||||
if grep -q "privacyModeEnabled = false" "$file"; then
|
||||
echo " ✅ Privacy mode default changed"
|
||||
fi
|
||||
;;
|
||||
*"ai_chat_screen.dart"*)
|
||||
if grep -q "flutter_markdown" "$file"; then
|
||||
echo " ✅ Markdown import added"
|
||||
fi
|
||||
if grep -q "MarkdownBody" "$file"; then
|
||||
echo " ✅ Markdown rendering added"
|
||||
fi
|
||||
if grep -q "_buildLoadingIndicator" "$file"; then
|
||||
echo " ✅ Loading indicator added"
|
||||
fi
|
||||
;;
|
||||
*"mistral_ai_service.dart"*)
|
||||
if grep -q "choices as List?" "$file"; then
|
||||
echo " ✅ JSON coercion fix applied"
|
||||
fi
|
||||
;;
|
||||
*"user_model.dart"*)
|
||||
if grep -q "final double? height" "$file"; then
|
||||
echo " ✅ Height field added"
|
||||
fi
|
||||
if grep -q "final double? weight" "$file"; then
|
||||
echo " ✅ Weight field added"
|
||||
fi
|
||||
;;
|
||||
*"sign_up_screen.dart"*)
|
||||
if grep -q "_heightController" "$file"; then
|
||||
echo " ✅ Height field added to sign-up"
|
||||
fi
|
||||
if grep -q "_weightController" "$file"; then
|
||||
echo " ✅ Weight field added to sign-up"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
else
|
||||
echo "❌ $file missing"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "🎯 Verification complete!"
|
||||
echo ""
|
||||
echo "📋 Summary of changes made:"
|
||||
echo "1. ✅ Fixed JSON coercion error in AI service"
|
||||
echo "2. ✅ Changed privacy mode default to false"
|
||||
echo "3. ✅ Added height/weight fields to User model"
|
||||
echo "4. ✅ Added height/weight fields to sign-up screen"
|
||||
echo "5. ✅ Added markdown rendering for AI responses"
|
||||
echo "6. ✅ Added loading indicator in chat"
|
||||
echo "7. ✅ Updated auth repository and controller"
|
||||
echo ""
|
||||
echo "🚀 To run on Linux machine:"
|
||||
echo " cd lifetimer"
|
||||
echo " flutter pub get"
|
||||
echo " flutter run"
|
||||
Reference in New Issue
Block a user