mirror of
https://github.com/Dvorinka/1356.git
synced 2026-06-04 12:02:56 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ab2773f98 | |||
| 7b7ed0083f | |||
| 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!
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<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
|
<application
|
||||||
android:label="lifetimer"
|
android:label="1356"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:usesCleartextTraffic="true">
|
android:usesCleartextTraffic="true">
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ class NextCountdownWidgetProvider : HomeWidgetProvider() {
|
|||||||
) {
|
) {
|
||||||
appWidgetIds.forEach { widgetId ->
|
appWidgetIds.forEach { widgetId ->
|
||||||
val title = widgetData.getString("next_title", "Next goal")
|
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 timeLeft = widgetData.getString("next_time_left", "0 days left")
|
||||||
|
|
||||||
val views = RemoteViews(context.packageName, R.layout.next_countdown_widget).apply {
|
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"
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:background="#121212"
|
android:background="#1A1A1A"
|
||||||
|
android:backgroundTint="#2A2A2A"
|
||||||
android:gravity="center_vertical"
|
android:gravity="center_vertical"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="12dp">
|
android:padding="12dp">
|
||||||
@@ -13,6 +14,7 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Next goal"
|
android:text="Next goal"
|
||||||
android:textColor="#FFFFFF"
|
android:textColor="#FFFFFF"
|
||||||
|
android:textStyle="bold"
|
||||||
android:textSize="16sp"
|
android:textSize="16sp"
|
||||||
android:maxLines="1"
|
android:maxLines="1"
|
||||||
android:ellipsize="end" />
|
android:ellipsize="end" />
|
||||||
@@ -23,7 +25,8 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="4dp"
|
android:layout_marginTop="4dp"
|
||||||
android:text="0 days left"
|
android:text="0 days left"
|
||||||
android:textColor="#FFCC66"
|
android:textColor="#4CAF50"
|
||||||
|
android:textStyle="bold"
|
||||||
android:textSize="20sp"
|
android:textSize="20sp"
|
||||||
android:maxLines="1"
|
android:maxLines="1"
|
||||||
android:ellipsize="end" />
|
android:ellipsize="end" />
|
||||||
@@ -33,7 +36,7 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="2dp"
|
android:layout_marginTop="2dp"
|
||||||
android:text="Open Lifetimer to see details"
|
android:text="Open 1356 to see details"
|
||||||
android:textColor="#B3FFFFFF"
|
android:textColor="#B3FFFFFF"
|
||||||
android:textSize="12sp"
|
android:textSize="12sp"
|
||||||
android:maxLines="2"
|
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>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Lifetimer</string>
|
<string>1356</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>lifetimer</string>
|
<string>1356</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
<string>Editor</string>
|
<string>Editor</string>
|
||||||
<key>CFBundleURLSchemes</key>
|
<key>CFBundleURLSchemes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>lifetimer</string>
|
<string>1356</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
<dict>
|
<dict>
|
||||||
|
|||||||
@@ -11,11 +11,25 @@ Future<void> bootstrap() async {
|
|||||||
await HomeWidget.setAppGroupId(Env.iosAppGroupId);
|
await HomeWidget.setAppGroupId(Env.iosAppGroupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
await Supabase.initialize(
|
// Only initialize Supabase if we have valid credentials
|
||||||
url: Env.supabaseUrl,
|
if (Env.supabaseUrl.isNotEmpty &&
|
||||||
anonKey: Env.supabaseAnonKey,
|
Env.supabaseUrl != 'https://your-project.supabase.co' &&
|
||||||
debug: true,
|
Env.supabaseAnonKey.isNotEmpty &&
|
||||||
);
|
Env.supabaseAnonKey != 'your-anon-key') {
|
||||||
|
try {
|
||||||
initializeSupabaseClient();
|
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,17 +1,35 @@
|
|||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
|
||||||
|
|
||||||
void initializeSupabaseClient() {
|
void initializeSupabaseClient() {
|
||||||
// Additional client setup if needed
|
// Additional client setup if needed
|
||||||
// For now, we use the default Supabase.instance.client
|
// 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)
|
// Service role client for admin operations (like creating user profiles)
|
||||||
// This should be used server-side or with proper security measures
|
// This should be used server-side or with proper security measures
|
||||||
SupabaseClient? _serviceRoleClient;
|
supabase.SupabaseClient? _serviceRoleClient;
|
||||||
|
|
||||||
SupabaseClient getServiceRoleClient() {
|
supabase.SupabaseClient getServiceRoleClient() {
|
||||||
if (_serviceRoleClient != null) return _serviceRoleClient!;
|
if (_serviceRoleClient != null) return _serviceRoleClient!;
|
||||||
|
|
||||||
// Note: In a production app, the service role key should be stored securely
|
// Note: In a production app, the service role key should be stored securely
|
||||||
@@ -22,12 +40,25 @@ SupabaseClient getServiceRoleClient() {
|
|||||||
const url = String.fromEnvironment('SUPABASE_URL');
|
const url = String.fromEnvironment('SUPABASE_URL');
|
||||||
|
|
||||||
if (serviceRoleKey.isNotEmpty && url.isNotEmpty) {
|
if (serviceRoleKey.isNotEmpty && url.isNotEmpty) {
|
||||||
_serviceRoleClient = SupabaseClient(url, serviceRoleKey);
|
_serviceRoleClient = supabase.SupabaseClient(url, serviceRoleKey);
|
||||||
return _serviceRoleClient!;
|
return _serviceRoleClient!;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Service role key not available, will use regular client
|
// Service role key not available, will use regular client
|
||||||
}
|
}
|
||||||
|
|
||||||
return supabaseClient;
|
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';
|
import 'package:hive/hive.dart';
|
||||||
|
|
||||||
part 'cached_goal.g.dart';
|
part 'cached_goal_model.g.dart';
|
||||||
|
|
||||||
@HiveType(typeId: 0)
|
@HiveType(typeId: 0)
|
||||||
class CachedGoal extends HiveObject {
|
class CachedGoal extends HiveObject {
|
||||||
|
|||||||
+37
-28
@@ -1,5 +1,11 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
part of 'cached_goal_model.dart';
|
part of 'cached_goal_model.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// TypeAdapterGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
class CachedGoalAdapter extends TypeAdapter<CachedGoal> {
|
class CachedGoalAdapter extends TypeAdapter<CachedGoal> {
|
||||||
@override
|
@override
|
||||||
final int typeId = 0;
|
final int typeId = 0;
|
||||||
@@ -29,33 +35,34 @@ class CachedGoalAdapter extends TypeAdapter<CachedGoal> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void write(BinaryWriter writer, CachedGoal obj) {
|
void write(BinaryWriter writer, CachedGoal obj) {
|
||||||
writer.writeByte(13);
|
writer
|
||||||
writer.writeByte(0);
|
..writeByte(13)
|
||||||
writer.write(obj.id);
|
..writeByte(0)
|
||||||
writer.writeByte(1);
|
..write(obj.id)
|
||||||
writer.write(obj.ownerId);
|
..writeByte(1)
|
||||||
writer.writeByte(2);
|
..write(obj.ownerId)
|
||||||
writer.write(obj.title);
|
..writeByte(2)
|
||||||
writer.writeByte(3);
|
..write(obj.title)
|
||||||
writer.write(obj.description);
|
..writeByte(3)
|
||||||
writer.writeByte(4);
|
..write(obj.description)
|
||||||
writer.write(obj.progress);
|
..writeByte(4)
|
||||||
writer.writeByte(5);
|
..write(obj.progress)
|
||||||
writer.write(obj.locationLat);
|
..writeByte(5)
|
||||||
writer.writeByte(6);
|
..write(obj.locationLat)
|
||||||
writer.write(obj.locationLng);
|
..writeByte(6)
|
||||||
writer.writeByte(7);
|
..write(obj.locationLng)
|
||||||
writer.write(obj.locationName);
|
..writeByte(7)
|
||||||
writer.writeByte(8);
|
..write(obj.locationName)
|
||||||
writer.write(obj.imageUrl);
|
..writeByte(8)
|
||||||
writer.writeByte(9);
|
..write(obj.imageUrl)
|
||||||
writer.write(obj.completed);
|
..writeByte(9)
|
||||||
writer.writeByte(10);
|
..write(obj.completed)
|
||||||
writer.write(obj.createdAt);
|
..writeByte(10)
|
||||||
writer.writeByte(11);
|
..write(obj.createdAt)
|
||||||
writer.write(obj.updatedAt);
|
..writeByte(11)
|
||||||
writer.writeByte(12);
|
..write(obj.updatedAt)
|
||||||
writer.write(obj.isDirty);
|
..writeByte(12)
|
||||||
|
..write(obj.isDirty);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -64,5 +71,7 @@ class CachedGoalAdapter extends TypeAdapter<CachedGoal> {
|
|||||||
@override
|
@override
|
||||||
bool operator ==(Object other) =>
|
bool operator ==(Object other) =>
|
||||||
identical(this, 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 'package:equatable/equatable.dart';
|
||||||
|
import '../../core/utils/unit_conversion_utils.dart';
|
||||||
|
|
||||||
class User extends Equatable {
|
class User extends Equatable {
|
||||||
final String id;
|
final String id;
|
||||||
@@ -13,6 +14,13 @@ class User extends Equatable {
|
|||||||
final String? websiteUrl;
|
final String? websiteUrl;
|
||||||
final DateTime? countdownStartDate;
|
final DateTime? countdownStartDate;
|
||||||
final DateTime? countdownEndDate;
|
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 createdAt;
|
||||||
final DateTime updatedAt;
|
final DateTime updatedAt;
|
||||||
|
|
||||||
@@ -29,6 +37,13 @@ class User extends Equatable {
|
|||||||
this.websiteUrl,
|
this.websiteUrl,
|
||||||
this.countdownStartDate,
|
this.countdownStartDate,
|
||||||
this.countdownEndDate,
|
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.createdAt,
|
||||||
required this.updatedAt,
|
required this.updatedAt,
|
||||||
});
|
});
|
||||||
@@ -45,6 +60,33 @@ class User extends Equatable {
|
|||||||
return countdownEndDate!.difference(DateTime.now()).inDays;
|
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({
|
User copyWith({
|
||||||
String? id,
|
String? id,
|
||||||
String? username,
|
String? username,
|
||||||
@@ -58,6 +100,13 @@ class User extends Equatable {
|
|||||||
String? websiteUrl,
|
String? websiteUrl,
|
||||||
DateTime? countdownStartDate,
|
DateTime? countdownStartDate,
|
||||||
DateTime? countdownEndDate,
|
DateTime? countdownEndDate,
|
||||||
|
Gender? gender,
|
||||||
|
DateTime? birthDate,
|
||||||
|
int? storedAge,
|
||||||
|
double? heightCm,
|
||||||
|
double? weightKg,
|
||||||
|
HeightUnit? heightUnit,
|
||||||
|
WeightUnit? weightUnit,
|
||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
}) {
|
}) {
|
||||||
@@ -74,6 +123,13 @@ class User extends Equatable {
|
|||||||
websiteUrl: websiteUrl ?? this.websiteUrl,
|
websiteUrl: websiteUrl ?? this.websiteUrl,
|
||||||
countdownStartDate: countdownStartDate ?? this.countdownStartDate,
|
countdownStartDate: countdownStartDate ?? this.countdownStartDate,
|
||||||
countdownEndDate: countdownEndDate ?? this.countdownEndDate,
|
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,
|
createdAt: createdAt ?? this.createdAt,
|
||||||
updatedAt: updatedAt ?? this.updatedAt,
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
);
|
);
|
||||||
@@ -93,6 +149,13 @@ class User extends Equatable {
|
|||||||
websiteUrl,
|
websiteUrl,
|
||||||
countdownStartDate,
|
countdownStartDate,
|
||||||
countdownEndDate,
|
countdownEndDate,
|
||||||
|
gender,
|
||||||
|
birthDate,
|
||||||
|
storedAge,
|
||||||
|
heightCm,
|
||||||
|
weightKg,
|
||||||
|
heightUnit,
|
||||||
|
weightUnit,
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
];
|
];
|
||||||
@@ -111,6 +174,13 @@ class User extends Equatable {
|
|||||||
'website_url': websiteUrl,
|
'website_url': websiteUrl,
|
||||||
'countdown_start_date': countdownStartDate?.toIso8601String(),
|
'countdown_start_date': countdownStartDate?.toIso8601String(),
|
||||||
'countdown_end_date': countdownEndDate?.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(),
|
'created_at': createdAt.toIso8601String(),
|
||||||
'updated_at': updatedAt.toIso8601String(),
|
'updated_at': updatedAt.toIso8601String(),
|
||||||
};
|
};
|
||||||
@@ -134,6 +204,15 @@ class User extends Equatable {
|
|||||||
countdownEndDate: json['countdown_end_date'] != null
|
countdownEndDate: json['countdown_end_date'] != null
|
||||||
? DateTime.parse(json['countdown_end_date'] as String)
|
? DateTime.parse(json['countdown_end_date'] as String)
|
||||||
: null,
|
: 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),
|
createdAt: DateTime.parse(json['created_at'] as String),
|
||||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import '../models/user_model.dart';
|
import '../models/user_model.dart';
|
||||||
import '../../bootstrap/supabase_client.dart';
|
import '../../bootstrap/supabase_client.dart';
|
||||||
|
import '../../core/utils/unit_conversion_utils.dart';
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
|
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
|
||||||
import 'package:google_sign_in/google_sign_in.dart';
|
import 'package:google_sign_in/google_sign_in.dart';
|
||||||
|
|
||||||
class AuthRepository {
|
class AuthRepository {
|
||||||
final supabase.SupabaseClient _client;
|
final supabase.SupabaseClient? _client;
|
||||||
StreamSubscription<supabase.AuthState>? _authStateSubscription;
|
StreamSubscription<supabase.AuthState>? _authStateSubscription;
|
||||||
|
|
||||||
AuthRepository([supabase.SupabaseClient? client]) : _client = client ?? supabaseClient;
|
AuthRepository([supabase.SupabaseClient? client]) : _client = client;
|
||||||
|
|
||||||
Stream<User?> get authStateChanges {
|
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;
|
final session = data.session;
|
||||||
if (session?.user != null) {
|
if (session?.user != null) {
|
||||||
return _mapSupabaseUserToAppUser(session!.user);
|
return _mapSupabaseUserToAppUser(session!.user);
|
||||||
@@ -21,39 +27,53 @@ class AuthRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
User? get currentUser {
|
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;
|
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 {
|
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;
|
if (session == null) return false;
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final expiresAt = session.expiresAt;
|
final expiresAt = session.expiresAt;
|
||||||
if (expiresAt == null) return true;
|
if (expiresAt == null) return true;
|
||||||
|
|
||||||
return now.isBefore(DateTime.fromMillisecondsSinceEpoch(expiresAt * 1000));
|
return now.isBefore(DateTime.fromMillisecondsSinceEpoch(expiresAt * 1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> refreshSession() async {
|
Future<void> refreshSession() async {
|
||||||
|
assert(_client != null, 'Client must not be null');
|
||||||
try {
|
try {
|
||||||
await _client.auth.refreshSession();
|
await _client!.auth.refreshSession();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw Exception('Failed to refresh session: $e');
|
throw Exception('Failed to refresh session: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<supabase.Session?> getCurrentSession() async {
|
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) {
|
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;
|
final session = data.session;
|
||||||
if (session?.user != null) {
|
if (session?.user != null) {
|
||||||
callback(_mapSupabaseUserToAppUser(session!.user));
|
callback(_mapSupabaseUserToAppUser(session!.user));
|
||||||
@@ -68,58 +88,89 @@ class AuthRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> signInWithEmail(String email, String password) async {
|
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 {
|
Future<void> signUpWithEmail(String email, String password, String username, {double? heightCm, double? weightKg, int? age, Gender? gender, HeightUnit? heightUnit, WeightUnit? weightUnit}) async {
|
||||||
final response = await _client.auth.signUp(
|
assert(_client != null, 'Client must not be null');
|
||||||
|
final response = await _client!.auth.signUp(
|
||||||
email: email,
|
email: email,
|
||||||
password: password,
|
password: password,
|
||||||
data: {'username': username},
|
data: {'username': username},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.user != null) {
|
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 {
|
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();
|
// Check if user is already signed in
|
||||||
if (googleUser == null) {
|
final googleUser = await googleSignIn.signInSilently();
|
||||||
throw Exception('Google sign-in was cancelled');
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
await _handleGoogleUser(interactiveUser);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Google sign-in failed: ${e.toString()}');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final googleAuth = await googleUser.authentication;
|
Future<void> _handleGoogleUser(dynamic googleUser) async {
|
||||||
final idToken = googleAuth.idToken;
|
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) {
|
if (idToken == null && accessToken == null) {
|
||||||
throw Exception('No ID token from Google sign-in');
|
throw Exception('No ID token or access token from Google sign-in');
|
||||||
}
|
}
|
||||||
|
|
||||||
final response = await _client.auth.signInWithIdToken(
|
final response = await _client!.auth.signInWithIdToken(
|
||||||
provider: supabase.OAuthProvider.google,
|
provider: supabase.OAuthProvider.google,
|
||||||
idToken: idToken,
|
idToken: idToken,
|
||||||
);
|
accessToken: accessToken,
|
||||||
|
);
|
||||||
|
|
||||||
if (response.user != null) {
|
if (response.user != null) {
|
||||||
await _ensureUserProfileExists(response.user!.id, response.user!);
|
await _ensureUserProfileExists(response.user!.id, response.user!);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Failed to authenticate with Google: ${e.toString()}');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> signInWithGithub() async {
|
Future<void> signInWithGithub() async {
|
||||||
await _client.auth.signInWithOAuth(
|
assert(_client != null, 'Client must not be null');
|
||||||
|
await _client!.auth.signInWithOAuth(
|
||||||
supabase.OAuthProvider.github,
|
supabase.OAuthProvider.github,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> signOut() async {
|
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 {
|
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({
|
Future<void> updateProfile({
|
||||||
@@ -127,8 +178,15 @@ class AuthRepository {
|
|||||||
String? bio,
|
String? bio,
|
||||||
String? avatarUrl,
|
String? avatarUrl,
|
||||||
bool? isPublicProfile,
|
bool? isPublicProfile,
|
||||||
|
double? heightCm,
|
||||||
|
double? weightKg,
|
||||||
|
int? age,
|
||||||
|
Gender? gender,
|
||||||
|
HeightUnit? heightUnit,
|
||||||
|
WeightUnit? weightUnit,
|
||||||
}) async {
|
}) 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');
|
if (userId == null) throw Exception('User not authenticated');
|
||||||
|
|
||||||
final updates = <String, dynamic>{};
|
final updates = <String, dynamic>{};
|
||||||
@@ -136,23 +194,36 @@ class AuthRepository {
|
|||||||
if (bio != null) updates['bio'] = bio;
|
if (bio != null) updates['bio'] = bio;
|
||||||
if (avatarUrl != null) updates['avatar_url'] = avatarUrl;
|
if (avatarUrl != null) updates['avatar_url'] = avatarUrl;
|
||||||
if (isPublicProfile != null) updates['is_public_profile'] = isPublicProfile;
|
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();
|
updates['updated_at'] = DateTime.now().toIso8601String();
|
||||||
|
|
||||||
await _client
|
await _client!
|
||||||
.from('users')
|
.from('users')
|
||||||
.update(updates)
|
.update(updates)
|
||||||
.eq('id', userId);
|
.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 now = DateTime.now().toIso8601String();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// First try with the regular client (might fail due to RLS)
|
// First try with the regular client (might fail due to RLS)
|
||||||
final response = await _client.from('users').insert({
|
final response = await _client!.from('users').insert({
|
||||||
'id': userId,
|
'id': userId,
|
||||||
'username': username,
|
'username': username,
|
||||||
'email': email,
|
'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,
|
'created_at': now,
|
||||||
'updated_at': now,
|
'updated_at': now,
|
||||||
}).select();
|
}).select();
|
||||||
@@ -168,6 +239,12 @@ class AuthRepository {
|
|||||||
'id': userId,
|
'id': userId,
|
||||||
'username': username,
|
'username': username,
|
||||||
'email': email,
|
'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,
|
'created_at': now,
|
||||||
'updated_at': now,
|
'updated_at': now,
|
||||||
}).select();
|
}).select();
|
||||||
@@ -182,6 +259,12 @@ class AuthRepository {
|
|||||||
id: userId,
|
id: userId,
|
||||||
username: username,
|
username: username,
|
||||||
email: email,
|
email: email,
|
||||||
|
storedAge: age,
|
||||||
|
heightCm: heightCm,
|
||||||
|
weightKg: weightKg,
|
||||||
|
gender: gender,
|
||||||
|
heightUnit: heightUnit ?? HeightUnit.metric,
|
||||||
|
weightUnit: weightUnit ?? WeightUnit.metric,
|
||||||
createdAt: DateTime.parse(now),
|
createdAt: DateTime.parse(now),
|
||||||
updatedAt: DateTime.parse(now),
|
updatedAt: DateTime.parse(now),
|
||||||
);
|
);
|
||||||
@@ -193,14 +276,21 @@ class AuthRepository {
|
|||||||
id: userId,
|
id: userId,
|
||||||
username: username,
|
username: username,
|
||||||
email: email,
|
email: email,
|
||||||
|
storedAge: age,
|
||||||
|
heightCm: heightCm,
|
||||||
|
weightKg: weightKg,
|
||||||
|
gender: gender,
|
||||||
|
heightUnit: heightUnit ?? HeightUnit.metric,
|
||||||
|
weightUnit: weightUnit ?? WeightUnit.metric,
|
||||||
createdAt: DateTime.parse(now),
|
createdAt: DateTime.parse(now),
|
||||||
updatedAt: DateTime.parse(now),
|
updatedAt: DateTime.parse(now),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _ensureUserProfileExists(String userId, dynamic supabaseUser) async {
|
Future<void> _ensureUserProfileExists(String userId, dynamic supabaseUser) async {
|
||||||
|
assert(_client != null, 'Client must not be null');
|
||||||
try {
|
try {
|
||||||
final existingProfile = await _client
|
final existingProfile = await _client!
|
||||||
.from('users')
|
.from('users')
|
||||||
.select('id')
|
.select('id')
|
||||||
.eq('id', userId)
|
.eq('id', userId)
|
||||||
@@ -246,6 +336,10 @@ class AuthRepository {
|
|||||||
countdownEndDate: data['countdown_end_date'] != null
|
countdownEndDate: data['countdown_end_date'] != null
|
||||||
? DateTime.parse(data['countdown_end_date'])
|
? DateTime.parse(data['countdown_end_date'])
|
||||||
: null,
|
: 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']),
|
createdAt: DateTime.parse(data['created_at']),
|
||||||
updatedAt: DateTime.parse(data['updated_at']),
|
updatedAt: DateTime.parse(data['updated_at']),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
|
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
|
||||||
import '../models/user_model.dart' as app;
|
import '../models/user_model.dart' as app;
|
||||||
import '../../core/errors/failure.dart';
|
import '../../core/errors/failure.dart';
|
||||||
|
import '../../core/utils/unit_conversion_utils.dart';
|
||||||
|
|
||||||
class UserRepository {
|
class UserRepository {
|
||||||
final supabase.SupabaseClient _client;
|
final supabase.SupabaseClient _client;
|
||||||
@@ -35,6 +36,12 @@ class UserRepository {
|
|||||||
String? instagramHandle,
|
String? instagramHandle,
|
||||||
String? tiktokHandle,
|
String? tiktokHandle,
|
||||||
String? websiteUrl,
|
String? websiteUrl,
|
||||||
|
Gender? gender,
|
||||||
|
DateTime? birthDate,
|
||||||
|
double? heightCm,
|
||||||
|
double? weightKg,
|
||||||
|
HeightUnit heightUnit = HeightUnit.metric,
|
||||||
|
WeightUnit weightUnit = WeightUnit.metric,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final updates = <String, dynamic>{};
|
final updates = <String, dynamic>{};
|
||||||
@@ -46,6 +53,12 @@ class UserRepository {
|
|||||||
if (instagramHandle != null) updates['instagram_handle'] = instagramHandle;
|
if (instagramHandle != null) updates['instagram_handle'] = instagramHandle;
|
||||||
if (tiktokHandle != null) updates['tiktok_handle'] = tiktokHandle;
|
if (tiktokHandle != null) updates['tiktok_handle'] = tiktokHandle;
|
||||||
if (websiteUrl != null) updates['website_url'] = websiteUrl;
|
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();
|
updates['updated_at'] = DateTime.now().toIso8601String();
|
||||||
|
|
||||||
final response = await _client
|
final response = await _client
|
||||||
|
|||||||
@@ -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) {
|
if (response.statusCode == 200) {
|
||||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
final choices = data['choices'] as List;
|
final choices = data['choices'] as List?;
|
||||||
final firstChoice = choices.first as Map<String, dynamic>;
|
if (choices == null || choices.isEmpty) {
|
||||||
final message = firstChoice['message'] as Map<String, dynamic>;
|
throw MistralAIException('No choices returned in response');
|
||||||
return message['content'] as String;
|
}
|
||||||
|
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 {
|
} else {
|
||||||
throw MistralAIException(
|
throw MistralAIException(
|
||||||
'Failed to get chat response',
|
'Failed to get chat response',
|
||||||
|
|||||||
@@ -6,88 +6,116 @@ class OfflineCacheService {
|
|||||||
static const String _userBoxName = 'cached_user';
|
static const String _userBoxName = 'cached_user';
|
||||||
static const String _countdownBoxName = 'cached_countdown';
|
static const String _countdownBoxName = 'cached_countdown';
|
||||||
|
|
||||||
late Box<CachedGoal> _goalsBox;
|
Box<CachedGoal>? _goalsBox;
|
||||||
late Box _userBox;
|
Box? _userBox;
|
||||||
late Box _countdownBox;
|
Box? _countdownBox;
|
||||||
|
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
await Hive.initFlutter();
|
try {
|
||||||
|
await Hive.initFlutter();
|
||||||
if (!Hive.isAdapterRegistered(0)) {
|
|
||||||
Hive.registerAdapter(CachedGoalAdapter());
|
if (!Hive.isAdapterRegistered(0)) {
|
||||||
|
Hive.registerAdapter(CachedGoalAdapter());
|
||||||
|
}
|
||||||
|
|
||||||
|
_goalsBox = await Hive.openBox<CachedGoal>(_goalsBoxName);
|
||||||
|
_userBox = await Hive.openBox(_userBoxName);
|
||||||
|
_countdownBox = await Hive.openBox(_countdownBoxName);
|
||||||
|
} catch (e) {
|
||||||
|
print('Error initializing offline cache: $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
_goalsBox = await Hive.openBox<CachedGoal>(_goalsBoxName);
|
|
||||||
_userBox = await Hive.openBox(_userBoxName);
|
|
||||||
_countdownBox = await Hive.openBox(_countdownBoxName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> cacheGoals(List<CachedGoal> goals) async {
|
Future<void> cacheGoals(List<CachedGoal> goals) async {
|
||||||
await _goalsBox.clear();
|
if (_goalsBox == null) return;
|
||||||
|
|
||||||
|
await _goalsBox!.clear();
|
||||||
for (var goal in goals) {
|
for (var goal in goals) {
|
||||||
await _goalsBox.put(goal.id, goal);
|
await _goalsBox!.put(goal.id, goal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<CachedGoal>> getCachedGoals() async {
|
Future<List<CachedGoal>> getCachedGoals() async {
|
||||||
return _goalsBox.values.toList();
|
if (_goalsBox == null) return [];
|
||||||
|
|
||||||
|
return _goalsBox!.values.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<CachedGoal?> getCachedGoal(String goalId) async {
|
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 {
|
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 {
|
Future<void> deleteCachedGoal(String goalId) async {
|
||||||
await _goalsBox.delete(goalId);
|
if (_goalsBox == null) return;
|
||||||
|
|
||||||
|
await _goalsBox!.delete(goalId);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> markGoalAsDirty(String goalId) async {
|
Future<void> markGoalAsDirty(String goalId) async {
|
||||||
final goal = _goalsBox.get(goalId);
|
if (_goalsBox == null) return;
|
||||||
|
|
||||||
|
final goal = _goalsBox!.get(goalId);
|
||||||
if (goal != null) {
|
if (goal != null) {
|
||||||
await _goalsBox.put(goalId, goal.copyWith(isDirty: true));
|
await _goalsBox!.put(goalId, goal.copyWith(isDirty: true));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<CachedGoal>> getDirtyGoals() async {
|
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 {
|
Future<void> clearDirtyFlag(String goalId) async {
|
||||||
final goal = _goalsBox.get(goalId);
|
if (_goalsBox == null) return;
|
||||||
|
|
||||||
|
final goal = _goalsBox!.get(goalId);
|
||||||
if (goal != null) {
|
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 {
|
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 {
|
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 {
|
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 {
|
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 {
|
Future<void> clearAllCache() async {
|
||||||
await _goalsBox.clear();
|
if (_goalsBox != null) await _goalsBox!.clear();
|
||||||
await _userBox.clear();
|
if (_userBox != null) await _userBox!.clear();
|
||||||
await _countdownBox.clear();
|
if (_countdownBox != null) await _countdownBox!.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> close() async {
|
Future<void> close() async {
|
||||||
await _goalsBox.close();
|
if (_goalsBox != null) await _goalsBox!.close();
|
||||||
await _userBox.close();
|
if (_userBox != null) await _userBox!.close();
|
||||||
await _countdownBox.close();
|
if (_countdownBox != null) await _countdownBox!.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class AchievementsState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class AchievementsController extends StateNotifier<AchievementsState> {
|
class AchievementsController extends StateNotifier<AchievementsState> {
|
||||||
final AchievementsRepository _repository;
|
final AchievementsRepository? _repository;
|
||||||
final AuthController _authController;
|
final AuthController _authController;
|
||||||
|
|
||||||
AchievementsController(
|
AchievementsController(
|
||||||
@@ -60,14 +60,16 @@ class AchievementsController extends StateNotifier<AchievementsState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadAchievements() async {
|
Future<void> _loadAchievements() async {
|
||||||
|
if (_repository == null) return;
|
||||||
|
|
||||||
final userId = _authController.currentUserId;
|
final userId = _authController.currentUserId;
|
||||||
if (userId == null) return;
|
if (userId == null) return;
|
||||||
|
|
||||||
state = state.copyWith(isLoading: true);
|
state = state.copyWith(isLoading: true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final available = await _repository.getAvailableAchievements();
|
final available = await _repository!.getAvailableAchievements();
|
||||||
final unlocked = await _repository.getUserAchievements(userId);
|
final unlocked = await _repository!.getUserAchievements(userId);
|
||||||
|
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -86,11 +88,13 @@ class AchievementsController extends StateNotifier<AchievementsState> {
|
|||||||
AchievementType type,
|
AchievementType type,
|
||||||
int currentValue,
|
int currentValue,
|
||||||
) async {
|
) async {
|
||||||
|
if (_repository == null) return null;
|
||||||
|
|
||||||
final userId = _authController.currentUserId;
|
final userId = _authController.currentUserId;
|
||||||
if (userId == null) return null;
|
if (userId == null) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final newlyUnlocked = await _repository.checkAndUnlockAchievement(
|
final newlyUnlocked = await _repository!.checkAndUnlockAchievement(
|
||||||
userId,
|
userId,
|
||||||
type,
|
type,
|
||||||
currentValue,
|
currentValue,
|
||||||
@@ -135,6 +139,9 @@ final achievementsControllerProvider =
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
final achievementsRepositoryProvider = Provider<AchievementsRepository>((ref) {
|
final achievementsRepositoryProvider = Provider<AchievementsRepository?>((ref) {
|
||||||
return AchievementsRepository(supabaseClient);
|
final client = supabaseClient;
|
||||||
|
if (client == null) return null;
|
||||||
|
|
||||||
|
return AchievementsRepository(client);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class AIChatState {
|
|||||||
this.isRecording = false,
|
this.isRecording = false,
|
||||||
this.error,
|
this.error,
|
||||||
this.currentTranscription,
|
this.currentTranscription,
|
||||||
this.privacyModeEnabled = true,
|
this.privacyModeEnabled = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
AIChatState copyWith({
|
AIChatState copyWith({
|
||||||
@@ -153,6 +153,13 @@ class AIChatController extends StateNotifier<AIChatState> {
|
|||||||
buffer.writeln(
|
buffer.writeln(
|
||||||
'User privacy mode is DISABLED. Use the following personal context to personalise your coaching:');
|
'User privacy mode is DISABLED. Use the following personal context to personalise your coaching:');
|
||||||
buffer.writeln('Username: ${user.username}.');
|
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) {
|
if (countdownSummary != null) {
|
||||||
buffer.writeln(countdownSummary);
|
buffer.writeln(countdownSummary);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||||
import '../application/ai_chat_controller.dart';
|
import '../application/ai_chat_controller.dart';
|
||||||
|
|
||||||
class AIChatScreen extends ConsumerStatefulWidget {
|
class AIChatScreen extends ConsumerStatefulWidget {
|
||||||
@@ -64,7 +65,7 @@ class _AIChatScreenState extends ConsumerState<AIChatScreen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: state.messages.isEmpty
|
child: state.messages.isEmpty
|
||||||
? _buildEmptyState(context)
|
? _buildEmptyState(context)
|
||||||
: _buildMessagesList(state.messages),
|
: _buildMessagesList(state.messages, state.isLoading),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Error message
|
// Error message
|
||||||
@@ -87,7 +88,7 @@ class _AIChatScreenState extends ConsumerState<AIChatScreen> {
|
|||||||
color: Theme.of(context)
|
color: Theme.of(context)
|
||||||
.colorScheme
|
.colorScheme
|
||||||
.onSurface
|
.onSurface
|
||||||
.withValues(alpha:0.7),
|
.withValues(alpha: 0.7),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -128,7 +129,7 @@ class _AIChatScreenState extends ConsumerState<AIChatScreen> {
|
|||||||
Text(
|
Text(
|
||||||
'Ask for goal inspiration, motivation, or life advice',
|
'Ask for goal inspiration, motivation, or life advice',
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha:0.7),
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
@@ -174,12 +175,16 @@ class _AIChatScreenState extends ConsumerState<AIChatScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMessagesList(List messages) {
|
Widget _buildMessagesList(List messages, bool isLoading) {
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
itemCount: messages.length,
|
itemCount: messages.length + (isLoading ? 1 : 0),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
|
if (index == messages.length && isLoading) {
|
||||||
|
return _buildLoadingIndicator();
|
||||||
|
}
|
||||||
|
|
||||||
final message = messages[index];
|
final message = messages[index];
|
||||||
final isUser = message.role == 'user';
|
final isUser = message.role == 'user';
|
||||||
|
|
||||||
@@ -218,14 +223,29 @@ class _AIChatScreenState extends ConsumerState<AIChatScreen> {
|
|||||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: isUser
|
||||||
message.content,
|
? Text(
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
message.content,
|
||||||
color: isUser
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
? Theme.of(context).colorScheme.onPrimary
|
color: Theme.of(context).colorScheme.onPrimary,
|
||||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
),
|
||||||
),
|
)
|
||||||
),
|
: 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (isUser) ...[
|
if (isUser) ...[
|
||||||
@@ -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) {
|
Widget _buildErrorMessage(String error, controller) {
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.all(16),
|
margin: const EdgeInsets.all(16),
|
||||||
@@ -317,7 +390,7 @@ class _AIChatScreenState extends ConsumerState<AIChatScreen> {
|
|||||||
color: Theme.of(context).colorScheme.surface,
|
color: Theme.of(context).colorScheme.surface,
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Theme.of(context).shadowColor.withValues(alpha:0.1),
|
color: Theme.of(context).shadowColor.withValues(alpha: 0.1),
|
||||||
blurRadius: 8,
|
blurRadius: 8,
|
||||||
offset: const Offset(0, -2),
|
offset: const Offset(0, -2),
|
||||||
),
|
),
|
||||||
@@ -362,8 +435,8 @@ class _AIChatScreenState extends ConsumerState<AIChatScreen> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: state.isRecording
|
color: state.isRecording
|
||||||
? Theme.of(context).colorScheme.error.withValues(alpha:0.12)
|
? Theme.of(context).colorScheme.error.withValues(alpha: 0.12)
|
||||||
: Theme.of(context).colorScheme.primary.withValues(alpha:0.08),
|
: Theme.of(context).colorScheme.primary.withValues(alpha: 0.08),
|
||||||
),
|
),
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
onPressed: state.isRecording
|
onPressed: state.isRecording
|
||||||
@@ -429,7 +502,7 @@ class _AIChatScreenState extends ConsumerState<AIChatScreen> {
|
|||||||
color: Theme.of(context)
|
color: Theme.of(context)
|
||||||
.colorScheme
|
.colorScheme
|
||||||
.primary
|
.primary
|
||||||
.withValues(alpha:state.isLoading ||
|
.withValues(alpha: state.isLoading ||
|
||||||
_textController.text.trim().isEmpty
|
_textController.text.trim().isEmpty
|
||||||
? 0.06
|
? 0.06
|
||||||
: 0.12),
|
: 0.12),
|
||||||
|
|||||||
@@ -271,9 +271,9 @@ final insightsControllerProvider =
|
|||||||
});
|
});
|
||||||
|
|
||||||
final goalsRepositoryProvider = Provider<GoalsRepository>((ref) {
|
final goalsRepositoryProvider = Provider<GoalsRepository>((ref) {
|
||||||
return GoalsRepository(supabaseClient);
|
return GoalsRepository(supabaseClient ?? (throw Exception("Supabase not initialized")));
|
||||||
});
|
});
|
||||||
|
|
||||||
final countdownRepositoryProvider = Provider<CountdownRepository>((ref) {
|
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 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../data/repositories/auth_repository.dart';
|
import '../../../data/repositories/auth_repository.dart';
|
||||||
import '../../../data/models/user_model.dart';
|
import '../../../data/models/user_model.dart';
|
||||||
|
import '../../../data/services/biometric_service.dart';
|
||||||
import '../../../core/services/analytics_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) {
|
final authControllerProvider = StateNotifierProvider<AuthController, User?>((ref) {
|
||||||
return AuthController(ref.read(authRepositoryProvider));
|
return AuthController(ref.read(authRepositoryProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
final authRepositoryProvider = Provider<AuthRepository>((ref) {
|
final authRepositoryProvider = Provider<AuthRepository>((ref) {
|
||||||
return AuthRepository();
|
return AuthRepository(supabaseClient);
|
||||||
});
|
});
|
||||||
|
|
||||||
class AuthController extends StateNotifier<User?> {
|
class AuthController extends StateNotifier<User?> {
|
||||||
final AuthRepository _authRepository;
|
final AuthRepository _authRepository;
|
||||||
|
final BiometricService _biometricService = BiometricService();
|
||||||
final AnalyticsService _analytics = AnalyticsService();
|
final AnalyticsService _analytics = AnalyticsService();
|
||||||
|
|
||||||
AuthController(this._authRepository) : super(null) {
|
AuthController(this._authRepository) : super(null) {
|
||||||
@@ -46,8 +51,8 @@ class AuthController extends StateNotifier<User?> {
|
|||||||
_analytics.logSignIn(method: 'email');
|
_analytics.logSignIn(method: 'email');
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
await _authRepository.signUpWithEmail(email, password, username);
|
await _authRepository.signUpWithEmail(email, password, username, heightCm: heightCm, weightKg: weightKg, age: age, gender: gender, heightUnit: heightUnit, weightUnit: weightUnit);
|
||||||
_analytics.logSignUp(method: 'email');
|
_analytics.logSignUp(method: 'email');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +82,12 @@ class AuthController extends StateNotifier<User?> {
|
|||||||
String? bio,
|
String? bio,
|
||||||
String? avatarUrl,
|
String? avatarUrl,
|
||||||
bool? isPublicProfile,
|
bool? isPublicProfile,
|
||||||
|
double? heightCm,
|
||||||
|
double? weightKg,
|
||||||
|
int? age,
|
||||||
|
Gender? gender,
|
||||||
|
HeightUnit? heightUnit,
|
||||||
|
WeightUnit? weightUnit,
|
||||||
}) async {
|
}) async {
|
||||||
final updatedFields = <String>[];
|
final updatedFields = <String>[];
|
||||||
if (username != null) updatedFields.add('username');
|
if (username != null) updatedFields.add('username');
|
||||||
@@ -86,12 +97,24 @@ class AuthController extends StateNotifier<User?> {
|
|||||||
updatedFields.add('visibility');
|
updatedFields.add('visibility');
|
||||||
_analytics.logProfileVisibilityChanged(isPublic: isPublicProfile);
|
_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(
|
await _authRepository.updateProfile(
|
||||||
username: username,
|
username: username,
|
||||||
bio: bio,
|
bio: bio,
|
||||||
avatarUrl: avatarUrl,
|
avatarUrl: avatarUrl,
|
||||||
isPublicProfile: isPublicProfile,
|
isPublicProfile: isPublicProfile,
|
||||||
|
heightCm: heightCm,
|
||||||
|
weightKg: weightKg,
|
||||||
|
age: age,
|
||||||
|
gender: gender,
|
||||||
|
heightUnit: heightUnit,
|
||||||
|
weightUnit: weightUnit,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (updatedFields.isNotEmpty) {
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_authRepository.dispose();
|
_authRepository.dispose();
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ class _AuthChoiceScreenState extends ConsumerState<AuthChoiceScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
'LifeTimer',
|
'1356',
|
||||||
style: Theme.of(context)
|
style: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
.headlineLarge
|
.headlineLarge
|
||||||
|
|||||||
@@ -2,18 +2,38 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../application/auth_controller.dart';
|
import '../application/auth_controller.dart';
|
||||||
import '../../onboarding/application/onboarding_controller.dart';
|
import '../../onboarding/application/onboarding_controller.dart';
|
||||||
|
import '../../profile/application/profile_controller.dart';
|
||||||
import 'auth_showcase_screen.dart';
|
import 'auth_showcase_screen.dart';
|
||||||
import '../../onboarding/presentation/onboarding_intro_screen.dart';
|
import '../../onboarding/presentation/onboarding_intro_screen.dart';
|
||||||
|
import '../../profile/presentation/profile_setup_screen.dart';
|
||||||
import '../../countdown/presentation/home_countdown_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});
|
const AuthGate({super.key});
|
||||||
|
|
||||||
@override
|
@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 authState = ref.watch(authControllerProvider);
|
||||||
final onboardingState = ref.watch(onboardingControllerProvider);
|
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) {
|
if (authState == null) {
|
||||||
return const AuthShowcaseScreen();
|
return const AuthShowcaseScreen();
|
||||||
}
|
}
|
||||||
@@ -23,7 +43,29 @@ class AuthGate extends ConsumerWidget {
|
|||||||
return const OnboardingIntroScreen();
|
return const OnboardingIntroScreen();
|
||||||
}
|
}
|
||||||
|
|
||||||
// User is authenticated and has completed onboarding
|
// 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();
|
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),
|
const SizedBox(height: 16),
|
||||||
Text(
|
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(
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
color: colorScheme.onSurface.withValues(alpha:0.7),
|
color: colorScheme.onSurface.withValues(alpha:0.7),
|
||||||
height: 1.6,
|
height: 1.6,
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.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/app_scaffold.dart';
|
||||||
import '../../../core/widgets/primary_button.dart';
|
import '../../../core/widgets/primary_button.dart';
|
||||||
import '../../../core/utils/validators.dart';
|
import '../../../core/utils/validators.dart';
|
||||||
import '../application/auth_controller.dart';
|
|
||||||
|
|
||||||
class SignInScreen extends ConsumerStatefulWidget {
|
class SignInScreen extends ConsumerStatefulWidget {
|
||||||
const SignInScreen({super.key});
|
const SignInScreen({super.key});
|
||||||
@@ -19,8 +21,86 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
|
|||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
final _emailController = TextEditingController();
|
final _emailController = TextEditingController();
|
||||||
final _passwordController = TextEditingController();
|
final _passwordController = TextEditingController();
|
||||||
|
final BiometricService _biometricService = BiometricService();
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
bool _obscurePassword = true;
|
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 {
|
Future<void> _handleSignIn() async {
|
||||||
if (!_formKey.currentState!.validate()) return;
|
if (!_formKey.currentState!.validate()) return;
|
||||||
@@ -111,7 +191,7 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'LifeTimer',
|
'1356',
|
||||||
style: Theme.of(context)
|
style: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
.titleMedium
|
.titleMedium
|
||||||
@@ -172,6 +252,45 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
|
|||||||
.withOpacity(0.7),
|
.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),
|
const SizedBox(height: 24),
|
||||||
Semantics(
|
Semantics(
|
||||||
label: 'Email address field',
|
label: 'Email address field',
|
||||||
@@ -240,6 +359,39 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
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(
|
PrimaryButton(
|
||||||
onPressed: _handleSignIn,
|
onPressed: _handleSignIn,
|
||||||
text: _isLoading ? 'Signing in...' : 'Sign In',
|
text: _isLoading ? 'Signing in...' : 'Sign In',
|
||||||
|
|||||||
@@ -3,10 +3,13 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../../../core/widgets/app_scaffold.dart';
|
import '../../../core/widgets/app_scaffold.dart';
|
||||||
import '../../../core/widgets/primary_button.dart';
|
import '../../../core/widgets/primary_button.dart';
|
||||||
|
import '../../../core/widgets/unit_input_field.dart';
|
||||||
import '../../../core/utils/validators.dart';
|
import '../../../core/utils/validators.dart';
|
||||||
import '../application/auth_controller.dart';
|
import '../application/auth_controller.dart';
|
||||||
|
import '../../../core/utils/unit_conversion_utils.dart';
|
||||||
|
|
||||||
class SignUpScreen extends ConsumerStatefulWidget {
|
class SignUpScreen extends ConsumerStatefulWidget {
|
||||||
const SignUpScreen({super.key});
|
const SignUpScreen({super.key});
|
||||||
@@ -21,20 +24,57 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
|||||||
final _passwordController = TextEditingController();
|
final _passwordController = TextEditingController();
|
||||||
final _confirmPasswordController = TextEditingController();
|
final _confirmPasswordController = TextEditingController();
|
||||||
final _usernameController = TextEditingController();
|
final _usernameController = TextEditingController();
|
||||||
|
final _ageController = TextEditingController();
|
||||||
|
Gender? _selectedGender;
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
bool _obscurePassword = true;
|
bool _obscurePassword = true;
|
||||||
bool _obscureConfirmPassword = true;
|
bool _obscureConfirmPassword = true;
|
||||||
|
bool _showAdditionalInfo = false;
|
||||||
|
double? _heightCm;
|
||||||
|
double? _weightKg;
|
||||||
|
HeightUnit _selectedHeightUnit = HeightUnit.metric;
|
||||||
|
WeightUnit _selectedWeightUnit = WeightUnit.metric;
|
||||||
|
|
||||||
Future<void> _handleSignUp() async {
|
Future<void> _handleSignUp() async {
|
||||||
if (!_formKey.currentState!.validate()) return;
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
try {
|
try {
|
||||||
|
final age = _ageController.text.trim().isNotEmpty
|
||||||
|
? int.tryParse(_ageController.text.trim())
|
||||||
|
: null;
|
||||||
|
|
||||||
await ref.read(authControllerProvider.notifier).signUpWithEmail(
|
await ref.read(authControllerProvider.notifier).signUpWithEmail(
|
||||||
_emailController.text.trim(),
|
_emailController.text.trim(),
|
||||||
_passwordController.text,
|
_passwordController.text,
|
||||||
_usernameController.text.trim(),
|
_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) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -54,6 +94,7 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
|||||||
_passwordController.dispose();
|
_passwordController.dispose();
|
||||||
_confirmPasswordController.dispose();
|
_confirmPasswordController.dispose();
|
||||||
_usernameController.dispose();
|
_usernameController.dispose();
|
||||||
|
_ageController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +132,7 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'LifeTimer',
|
'1356',
|
||||||
style: Theme.of(context)
|
style: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
.titleMedium
|
.titleMedium
|
||||||
@@ -234,6 +275,121 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
|||||||
enabled: !_isLoading,
|
enabled: !_isLoading,
|
||||||
onFieldSubmitted: (_) => _handleSignUp(),
|
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),
|
const SizedBox(height: 24),
|
||||||
PrimaryButton(
|
PrimaryButton(
|
||||||
onPressed: _handleSignUp,
|
onPressed: _handleSignUp,
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ class CalendarController extends StateNotifier<CalendarState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final calendarRepositoryProvider = Provider<CalendarRepository>((ref) {
|
final calendarRepositoryProvider = Provider<CalendarRepository>((ref) {
|
||||||
return CalendarRepository(supabaseClient);
|
return CalendarRepository(supabaseClient ?? (throw Exception("Supabase not initialized")));
|
||||||
});
|
});
|
||||||
|
|
||||||
final calendarControllerProvider =
|
final calendarControllerProvider =
|
||||||
|
|||||||
@@ -522,6 +522,7 @@ Future<void> _showAddCalendarEntrySheet(
|
|||||||
],
|
],
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
|
final navigator = Navigator.of(sheetContext);
|
||||||
await ref
|
await ref
|
||||||
.read(calendarControllerProvider.notifier)
|
.read(calendarControllerProvider.notifier)
|
||||||
.addEntry(
|
.addEntry(
|
||||||
@@ -529,8 +530,8 @@ Future<void> _showAddCalendarEntrySheet(
|
|||||||
note: noteController.text,
|
note: noteController.text,
|
||||||
goalId: selectedGoalId,
|
goalId: selectedGoalId,
|
||||||
);
|
);
|
||||||
if (Navigator.of(sheetContext).canPop()) {
|
if (navigator.canPop()) {
|
||||||
Navigator.of(sheetContext).pop();
|
navigator.pop();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: const Text('Save to calendar'),
|
child: const Text('Save to calendar'),
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ class CountdownLoaded extends CountdownState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final countdownRepositoryProvider = Provider<CountdownRepository>((ref) {
|
final countdownRepositoryProvider = Provider<CountdownRepository>((ref) {
|
||||||
return CountdownRepository(supabaseClient);
|
return CountdownRepository(supabaseClient ?? (throw Exception("Supabase not initialized")));
|
||||||
});
|
});
|
||||||
|
|
||||||
final countdownControllerProvider = StateNotifierProvider<CountdownController, CountdownState>((ref) {
|
final countdownControllerProvider = StateNotifierProvider<CountdownController, CountdownState>((ref) {
|
||||||
|
|||||||
@@ -28,30 +28,35 @@ class _HomeCountdownScreenState extends ConsumerState<HomeCountdownScreen> {
|
|||||||
? achievementsState.level
|
? achievementsState.level
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
final child = countdownState.isLoading
|
||||||
|
? const Center(child: LoadingIndicator())
|
||||||
|
: countdownState.error != null
|
||||||
|
? Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text('Error: ${countdownState.error}'),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => context.go('/'),
|
||||||
|
child: const Text('Retry'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: countdownState.user == null || !countdownState.user!.hasCountdownStarted
|
||||||
|
? _CountdownNotStartedScreen()
|
||||||
|
: _CountdownActiveScreen(
|
||||||
|
user: countdownState.user!,
|
||||||
|
level: level,
|
||||||
|
);
|
||||||
|
|
||||||
return AppScaffold(
|
return AppScaffold(
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: countdownState.isLoading
|
child: Padding(
|
||||||
? const Center(child: LoadingIndicator())
|
padding: const EdgeInsets.only(top: 30.0, bottom: 30.0),
|
||||||
: countdownState.error != null
|
child: child,
|
||||||
? Center(
|
),
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Text('Error: ${countdownState.error}'),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () => context.go('/'),
|
|
||||||
child: const Text('Retry'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: countdownState.user == null || !countdownState.user!.hasCountdownStarted
|
|
||||||
? _CountdownNotStartedScreen()
|
|
||||||
: _CountdownActiveScreen(
|
|
||||||
user: countdownState.user!,
|
|
||||||
level: level,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
onPressed: () => context.push('/ai-chat'),
|
onPressed: () => context.push('/ai-chat'),
|
||||||
@@ -156,7 +161,7 @@ class _CountdownActiveScreen extends StatelessWidget {
|
|||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
child: Padding(
|
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(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -154,7 +154,11 @@ class GoalsState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final goalsRepositoryProvider = Provider<GoalsRepository>((ref) {
|
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) {
|
final goalsControllerProvider = StateNotifierProvider<GoalsController, GoalsState>((ref) {
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ class OnboardingController extends StateNotifier<bool> {
|
|||||||
static const String _onboardingKey = 'onboarding_completed';
|
static const String _onboardingKey = 'onboarding_completed';
|
||||||
|
|
||||||
OnboardingController() : super(false) {
|
OnboardingController() : super(false) {
|
||||||
_loadOnboardingStatus();
|
loadOnboardingStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadOnboardingStatus() async {
|
Future<void> loadOnboardingStatus() async {
|
||||||
try {
|
try {
|
||||||
final box = await Hive.openBox('app_settings');
|
final box = await Hive.openBox('app_settings');
|
||||||
final completed = box.get(_onboardingKey, defaultValue: false);
|
final completed = box.get(_onboardingKey, defaultValue: false);
|
||||||
|
|||||||
+93
-67
@@ -16,82 +16,67 @@ class OnboardingHowItWorksScreen extends ConsumerWidget {
|
|||||||
|
|
||||||
return AppScaffold(
|
return AppScaffold(
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Column(
|
child: Padding(
|
||||||
children: [
|
padding: const EdgeInsets.only(top: 30.0, left: 24.0, right: 24.0, bottom: 30.0),
|
||||||
// Progress indicator and back button
|
child: Column(
|
||||||
Padding(
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
padding: const EdgeInsets.all(24.0),
|
children: [
|
||||||
child: Row(
|
// Progress Bar and Navigation
|
||||||
children: [
|
const _OnboardingProgress(currentStep: 2, totalSteps: 3),
|
||||||
IconButton(
|
const SizedBox(height: 24),
|
||||||
onPressed: () {
|
Text(
|
||||||
context.pop();
|
'How It Works',
|
||||||
},
|
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
|
||||||
icon: const Icon(Icons.arrow_back),
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
Expanded(
|
textAlign: TextAlign.center,
|
||||||
child: LinearProgressIndicator(
|
|
||||||
value: 2 / 3, // Step 2 of 3
|
|
||||||
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(
|
|
||||||
Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 48), // Balance the back button
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 32),
|
||||||
// Scrollable content
|
const _StepCard(
|
||||||
Expanded(
|
number: 1,
|
||||||
child: SingleChildScrollView(
|
title: 'Create Your Bucket List',
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24.0),
|
description: 'Add between 1 and 20 goals you want to achieve. Each goal can have a description, location, and image.',
|
||||||
child: Column(
|
icon: Icons.edit_note,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
),
|
||||||
children: [
|
const SizedBox(height: 16),
|
||||||
const SizedBox(height: 8),
|
const _StepCard(
|
||||||
Text(
|
number: 2,
|
||||||
'How It Works',
|
title: 'Finalize Your List',
|
||||||
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
|
description: 'Once you\'re happy with your goals, confirm your bucket list. This action cannot be undone.',
|
||||||
fontWeight: FontWeight.bold,
|
icon: Icons.lock,
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
const SizedBox(height: 16),
|
||||||
|
const _StepCard(
|
||||||
|
number: 3,
|
||||||
|
title: 'Start Your 1356-Day Journey',
|
||||||
|
description: 'The countdown begins immediately. Track your progress and make every day count.',
|
||||||
|
icon: Icons.timer,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: () => context.pop(),
|
||||||
|
child: const Text('Back'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
),
|
||||||
const _StepCard(
|
const SizedBox(width: 16),
|
||||||
number: 1,
|
Expanded(
|
||||||
title: 'Create Your Bucket List',
|
flex: 2,
|
||||||
description: 'Add between 1 and 20 goals you want to achieve. Each goal can have a description, location, and image.',
|
child: PrimaryButton(
|
||||||
icon: Icons.edit_note,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
const _StepCard(
|
|
||||||
number: 2,
|
|
||||||
title: 'Finalize Your List',
|
|
||||||
description: 'Once you\'re happy with your goals, confirm your bucket list. This action cannot be undone.',
|
|
||||||
icon: Icons.lock,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
const _StepCard(
|
|
||||||
number: 3,
|
|
||||||
title: 'Start Your 1356-Day Journey',
|
|
||||||
description: 'The countdown begins immediately. Track your progress and make every day count.',
|
|
||||||
icon: Icons.timer,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
PrimaryButton(
|
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
controller.completeStep('how_it_works');
|
controller.completeStep('how_it_works');
|
||||||
context.push('/onboarding/motivation');
|
context.push('/onboarding/motivation');
|
||||||
},
|
},
|
||||||
text: 'Continue',
|
text: 'Continue',
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -190,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,105 +16,86 @@ class OnboardingIntroScreen extends ConsumerWidget {
|
|||||||
|
|
||||||
return AppScaffold(
|
return AppScaffold(
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Column(
|
child: SingleChildScrollView(
|
||||||
children: [
|
child: Padding(
|
||||||
// Progress indicator and back button
|
padding: const EdgeInsets.only(top: 20.0, left: 24.0, right: 24.0, bottom: 20.0),
|
||||||
Padding(
|
child: Column(
|
||||||
padding: const EdgeInsets.all(24.0),
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
child: Row(
|
children: [
|
||||||
children: [
|
// Progress Bar and Navigation
|
||||||
IconButton(
|
const _OnboardingProgress(currentStep: 1, totalSteps: 3),
|
||||||
onPressed: () {
|
const SizedBox(height: 48),
|
||||||
// Can't go back from intro, go to auth choice
|
const Icon(
|
||||||
context.push('/auth-choice');
|
Icons.timer_outlined,
|
||||||
},
|
size: 100,
|
||||||
icon: const Icon(Icons.arrow_back),
|
color: null,
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: LinearProgressIndicator(
|
|
||||||
value: 1 / 3, // Step 1 of 3
|
|
||||||
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(
|
|
||||||
Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 48), // Balance the back button
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 32),
|
||||||
// Scrollable content
|
Text(
|
||||||
Expanded(
|
'Welcome to 1356',
|
||||||
child: SingleChildScrollView(
|
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24.0),
|
fontWeight: FontWeight.bold,
|
||||||
child: Column(
|
),
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
textAlign: TextAlign.center,
|
||||||
children: [
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Icon(
|
Text(
|
||||||
Icons.timer_outlined,
|
'Your 1356-day journey starts here.\nCreate your bucket list and begin your countdown.',
|
||||||
size: 64,
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
color: null,
|
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
|
||||||
),
|
height: 1.5,
|
||||||
const SizedBox(height: 20),
|
),
|
||||||
Text(
|
textAlign: TextAlign.center,
|
||||||
'Welcome to LifeTimer',
|
),
|
||||||
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
|
const SizedBox(height: 48),
|
||||||
fontWeight: FontWeight.bold,
|
const _FeatureCard(
|
||||||
),
|
icon: Icons.flag,
|
||||||
textAlign: TextAlign.center,
|
title: 'Set Your Goals',
|
||||||
),
|
description: 'Create a bucket list of 1-20 meaningful goals',
|
||||||
const SizedBox(height: 10),
|
),
|
||||||
Text(
|
const SizedBox(height: 16),
|
||||||
'Your 1356-day journey starts here.\nCreate your bucket list and begin your countdown.',
|
const _FeatureCard(
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
icon: Icons.lock_clock,
|
||||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
|
title: 'Fixed Timeline',
|
||||||
height: 1.5,
|
description: '1356 days to achieve everything - no extensions',
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
const SizedBox(height: 16),
|
||||||
),
|
const _FeatureCard(
|
||||||
const SizedBox(height: 24),
|
icon: Icons.trending_up,
|
||||||
const _FeatureCard(
|
title: 'Track Progress',
|
||||||
icon: Icons.flag,
|
description: 'Watch yourself grow day by day',
|
||||||
title: 'Set Your Goals',
|
),
|
||||||
description: 'Create a bucket list of 1-20 meaningful goals',
|
const SizedBox(height: 48),
|
||||||
),
|
Row(
|
||||||
const SizedBox(height: 10),
|
children: [
|
||||||
const _FeatureCard(
|
Expanded(
|
||||||
icon: Icons.lock_clock,
|
child: TextButton(
|
||||||
title: 'Fixed Timeline',
|
|
||||||
description: '1356 days to achieve everything - no extensions',
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
const _FeatureCard(
|
|
||||||
icon: Icons.trending_up,
|
|
||||||
title: 'Track Progress',
|
|
||||||
description: 'Watch yourself grow day by day',
|
|
||||||
),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
PrimaryButton(
|
|
||||||
onPressed: () {
|
|
||||||
controller.completeStep('intro');
|
|
||||||
context.push('/onboarding/how-it-works');
|
|
||||||
},
|
|
||||||
text: 'Get Started',
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await controller.skipOnboarding();
|
await controller.skipOnboarding();
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
context.push('/home');
|
context.push('/home');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: const Text('Skip onboarding'),
|
child: const Text('Skip'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
),
|
||||||
],
|
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),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -176,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,100 +16,73 @@ class OnboardingMotivationScreen extends ConsumerWidget {
|
|||||||
|
|
||||||
return AppScaffold(
|
return AppScaffold(
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Column(
|
child: SingleChildScrollView(
|
||||||
children: [
|
child: Padding(
|
||||||
// Progress indicator and back button
|
padding: const EdgeInsets.only(top: 30.0, left: 24.0, right: 24.0, bottom: 30.0),
|
||||||
Padding(
|
child: Column(
|
||||||
padding: const EdgeInsets.all(24.0),
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
child: Row(
|
children: [
|
||||||
children: [
|
// Progress Bar and Navigation
|
||||||
IconButton(
|
const _OnboardingProgress(currentStep: 3, totalSteps: 3),
|
||||||
onPressed: () {
|
const SizedBox(height: 24),
|
||||||
context.pop();
|
const Icon(
|
||||||
},
|
Icons.psychology_outlined,
|
||||||
icon: const Icon(Icons.arrow_back),
|
size: 80,
|
||||||
),
|
color: Colors.amber,
|
||||||
Expanded(
|
|
||||||
child: LinearProgressIndicator(
|
|
||||||
value: 3 / 3, // Step 3 of 3 (complete)
|
|
||||||
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(
|
|
||||||
Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 48), // Balance the back button
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Scrollable content
|
|
||||||
Expanded(
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24.0),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
const Icon(
|
|
||||||
Icons.psychology_outlined,
|
|
||||||
size: 64,
|
|
||||||
color: Colors.amber,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'Your Time is Now',
|
|
||||||
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(
|
|
||||||
'1356 days is approximately 3 years and 8 months.\n\n'
|
|
||||||
'That\'s enough time to transform your life, learn new skills, '
|
|
||||||
'build meaningful relationships, and achieve your biggest dreams.\n\n'
|
|
||||||
'Every day counts. Every step matters. Your journey begins now.',
|
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
|
||||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
|
|
||||||
height: 1.5,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
const _MotivationCard(
|
|
||||||
icon: Icons.trending_up,
|
|
||||||
title: 'Track Progress',
|
|
||||||
description: 'Watch yourself grow as you complete goals and milestones.',
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
const _MotivationCard(
|
|
||||||
icon: Icons.people,
|
|
||||||
title: 'Join Community',
|
|
||||||
description: 'Connect with others on similar journeys (optional).',
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
const _MotivationCard(
|
|
||||||
icon: Icons.celebration,
|
|
||||||
title: 'Celebrate Wins',
|
|
||||||
description: 'Every achievement is worth celebrating.',
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
PrimaryButton(
|
|
||||||
onPressed: () async {
|
|
||||||
controller.completeStep('motivation');
|
|
||||||
await controller.completeOnboarding();
|
|
||||||
if (context.mounted) {
|
|
||||||
context.push('/profile-setup');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
text: 'Get Started',
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
'Your Time is Now',
|
||||||
|
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'1356 days is approximately 3 years and 8 months.\n\n'
|
||||||
|
'That\'s enough time to transform your life, learn new skills, '
|
||||||
|
'build meaningful relationships, and achieve your biggest dreams.\n\n'
|
||||||
|
'Every day counts. Every step matters. Your journey begins now.',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
const _MotivationCard(
|
||||||
|
icon: Icons.trending_up,
|
||||||
|
title: 'Track Progress',
|
||||||
|
description: 'Watch yourself grow as you complete goals and milestones.',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const _MotivationCard(
|
||||||
|
icon: Icons.people,
|
||||||
|
title: 'Join Community',
|
||||||
|
description: 'Connect with others on similar journeys (optional).',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const _MotivationCard(
|
||||||
|
icon: Icons.celebration,
|
||||||
|
title: 'Celebrate Wins',
|
||||||
|
description: 'Every achievement is worth celebrating.',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
PrimaryButton(
|
||||||
|
onPressed: () async {
|
||||||
|
controller.completeStep('motivation');
|
||||||
|
await controller.completeOnboarding();
|
||||||
|
if (context.mounted) {
|
||||||
|
context.push('/profile/create');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
text: 'Get Started',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -171,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:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
|
|
||||||
import '../../../data/models/user_model.dart' as app;
|
import '../../../data/models/user_model.dart' as app;
|
||||||
import '../../../data/repositories/user_repository.dart';
|
import '../../../data/repositories/user_repository.dart';
|
||||||
|
import '../../../bootstrap/supabase_client.dart';
|
||||||
import '../../../core/errors/failure.dart';
|
import '../../../core/errors/failure.dart';
|
||||||
|
import '../../../core/utils/unit_conversion_utils.dart';
|
||||||
|
|
||||||
final profileControllerProvider = StateNotifierProvider<ProfileController, ProfileState>((ref) {
|
final profileControllerProvider = StateNotifierProvider<ProfileController, ProfileState>((ref) {
|
||||||
final client = supabase.Supabase.instance.client;
|
final client = supabaseClient;
|
||||||
final repository = UserRepository(client);
|
final repository = client != null ? UserRepository(client) : null;
|
||||||
return ProfileController(repository);
|
return ProfileController(repository);
|
||||||
});
|
});
|
||||||
|
|
||||||
class ProfileController extends StateNotifier<ProfileState> {
|
class ProfileController extends StateNotifier<ProfileState> {
|
||||||
final UserRepository _repository;
|
final UserRepository? _repository;
|
||||||
|
|
||||||
ProfileController(this._repository) : super(const ProfileState.initial());
|
ProfileController(this._repository) : super(const ProfileState.initial());
|
||||||
|
|
||||||
Future<void> loadProfile(String userId) async {
|
Future<void> loadProfile(String userId) async {
|
||||||
|
if (_repository == null) {
|
||||||
|
state = const ProfileState.error('Profile is unavailable while offline.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
state = const ProfileState.loading();
|
state = const ProfileState.loading();
|
||||||
try {
|
try {
|
||||||
final user = await _repository.getProfile(userId);
|
final user = await _repository!.getProfile(userId);
|
||||||
state = ProfileState.loaded(user);
|
state = ProfileState.loaded(user);
|
||||||
} on Failure catch (failure) {
|
} on Failure catch (failure) {
|
||||||
state = ProfileState.error(failure.message);
|
state = ProfileState.error(failure.message);
|
||||||
@@ -28,15 +34,20 @@ class ProfileController extends StateNotifier<ProfileState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateUsername(String userId, String username) async {
|
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();
|
state = const ProfileState.loading();
|
||||||
try {
|
try {
|
||||||
final isAvailable = await _repository.isUsernameAvailable(username);
|
final isAvailable = await _repository!.isUsernameAvailable(username);
|
||||||
if (!isAvailable) {
|
if (!isAvailable) {
|
||||||
state = const ProfileState.error('Username is already taken');
|
state = const ProfileState.error('Username is already taken');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final updatedUser = await _repository.updateProfile(
|
final updatedUser = await _repository!.updateProfile(
|
||||||
userId: userId,
|
userId: userId,
|
||||||
username: username,
|
username: username,
|
||||||
);
|
);
|
||||||
@@ -49,9 +60,14 @@ class ProfileController extends StateNotifier<ProfileState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateBio(String userId, String bio) async {
|
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();
|
state = const ProfileState.loading();
|
||||||
try {
|
try {
|
||||||
final updatedUser = await _repository.updateProfile(
|
final updatedUser = await _repository!.updateProfile(
|
||||||
userId: userId,
|
userId: userId,
|
||||||
bio: bio,
|
bio: bio,
|
||||||
);
|
);
|
||||||
@@ -64,9 +80,14 @@ class ProfileController extends StateNotifier<ProfileState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateAvatarUrl(String userId, String avatarUrl) async {
|
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();
|
state = const ProfileState.loading();
|
||||||
try {
|
try {
|
||||||
final updatedUser = await _repository.updateProfile(
|
final updatedUser = await _repository!.updateProfile(
|
||||||
userId: userId,
|
userId: userId,
|
||||||
avatarUrl: avatarUrl,
|
avatarUrl: avatarUrl,
|
||||||
);
|
);
|
||||||
@@ -79,12 +100,17 @@ class ProfileController extends StateNotifier<ProfileState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> toggleProfileVisibility(String userId) async {
|
Future<void> toggleProfileVisibility(String userId) async {
|
||||||
|
if (_repository == null) {
|
||||||
|
state = const ProfileState.error('Profile is unavailable while offline.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final currentState = state;
|
final currentState = state;
|
||||||
if (currentState.user == null) return;
|
if (currentState.user == null) return;
|
||||||
|
|
||||||
state = const ProfileState.loading();
|
state = const ProfileState.loading();
|
||||||
try {
|
try {
|
||||||
final updatedUser = await _repository.updateProfile(
|
final updatedUser = await _repository!.updateProfile(
|
||||||
userId: userId,
|
userId: userId,
|
||||||
isPublicProfile: !currentState.user!.isPublicProfile,
|
isPublicProfile: !currentState.user!.isPublicProfile,
|
||||||
);
|
);
|
||||||
@@ -105,16 +131,27 @@ class ProfileController extends StateNotifier<ProfileState> {
|
|||||||
String? instagramHandle,
|
String? instagramHandle,
|
||||||
String? tiktokHandle,
|
String? tiktokHandle,
|
||||||
String? websiteUrl,
|
String? websiteUrl,
|
||||||
|
Gender? gender,
|
||||||
|
DateTime? birthDate,
|
||||||
|
double? heightCm,
|
||||||
|
double? weightKg,
|
||||||
|
HeightUnit heightUnit = HeightUnit.metric,
|
||||||
|
WeightUnit weightUnit = WeightUnit.metric,
|
||||||
}) async {
|
}) async {
|
||||||
|
if (_repository == null) {
|
||||||
|
state = const ProfileState.error('Profile is unavailable while offline.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
state = const ProfileState.loading();
|
state = const ProfileState.loading();
|
||||||
try {
|
try {
|
||||||
final isAvailable = await _repository.isUsernameAvailable(username);
|
final isAvailable = await _repository!.isUsernameAvailable(username);
|
||||||
if (!isAvailable) {
|
if (!isAvailable) {
|
||||||
state = const ProfileState.error('Username is already taken');
|
state = const ProfileState.error('Username is already taken');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final updatedUser = await _repository.updateProfile(
|
final updatedUser = await _repository!.updateProfile(
|
||||||
userId: userId,
|
userId: userId,
|
||||||
username: username,
|
username: username,
|
||||||
bio: bio,
|
bio: bio,
|
||||||
@@ -123,6 +160,12 @@ class ProfileController extends StateNotifier<ProfileState> {
|
|||||||
instagramHandle: instagramHandle,
|
instagramHandle: instagramHandle,
|
||||||
tiktokHandle: tiktokHandle,
|
tiktokHandle: tiktokHandle,
|
||||||
websiteUrl: websiteUrl,
|
websiteUrl: websiteUrl,
|
||||||
|
gender: gender,
|
||||||
|
birthDate: birthDate,
|
||||||
|
heightCm: heightCm,
|
||||||
|
weightKg: weightKg,
|
||||||
|
heightUnit: heightUnit,
|
||||||
|
weightUnit: weightUnit,
|
||||||
);
|
);
|
||||||
state = ProfileState.loaded(updatedUser);
|
state = ProfileState.loaded(updatedUser);
|
||||||
} on Failure catch (failure) {
|
} on Failure catch (failure) {
|
||||||
@@ -131,6 +174,19 @@ class ProfileController extends StateNotifier<ProfileState> {
|
|||||||
state = ProfileState.error(e.toString());
|
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 {
|
class ProfileState {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.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/app_scaffold.dart';
|
||||||
import '../../../core/widgets/loading_indicator.dart';
|
import '../../../core/widgets/loading_indicator.dart';
|
||||||
import '../../../core/widgets/empty_state.dart';
|
import '../../../core/widgets/empty_state.dart';
|
||||||
@@ -23,7 +23,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
final userId = supabase.Supabase.instance.client.auth.currentUser?.id;
|
final userId = currentSupabaseUserId;
|
||||||
if (userId != null) {
|
if (userId != null) {
|
||||||
ref.read(profileControllerProvider.notifier).loadProfile(userId);
|
ref.read(profileControllerProvider.notifier).loadProfile(userId);
|
||||||
}
|
}
|
||||||
@@ -33,10 +33,11 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final profileState = ref.watch(profileControllerProvider);
|
final profileState = ref.watch(profileControllerProvider);
|
||||||
final achievementsState = ref.watch(achievementsControllerProvider);
|
final achievementsState = ref.watch(achievementsControllerProvider);
|
||||||
final userId = supabase.Supabase.instance.client.auth.currentUser?.id;
|
final userId = currentSupabaseUserId;
|
||||||
|
|
||||||
if (userId == null) {
|
if (userId == null) {
|
||||||
return AppScaffold(
|
return AppScaffold(
|
||||||
|
title: 'Profile',
|
||||||
body: Semantics(
|
body: Semantics(
|
||||||
label: 'Not signed in',
|
label: 'Not signed in',
|
||||||
child: const EmptyState(
|
child: const EmptyState(
|
||||||
@@ -105,6 +106,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
|||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 200,
|
expandedHeight: 200,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
|
title: const Text('Profile'),
|
||||||
flexibleSpace: FlexibleSpaceBar(
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
background: Container(
|
background: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -408,7 +410,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed == true && context.mounted) {
|
if (confirmed == true && context.mounted) {
|
||||||
await supabase.Supabase.instance.client.auth.signOut();
|
await signOutCurrentSupabaseUser();
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
context.go('/');
|
context.go('/');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
|
|||||||
import '../../../core/widgets/app_scaffold.dart';
|
import '../../../core/widgets/app_scaffold.dart';
|
||||||
import '../../../core/widgets/primary_button.dart';
|
import '../../../core/widgets/primary_button.dart';
|
||||||
import '../../../core/utils/validators.dart';
|
import '../../../core/utils/validators.dart';
|
||||||
|
import '../../../core/utils/unit_conversion_utils.dart';
|
||||||
import '../application/profile_controller.dart';
|
import '../application/profile_controller.dart';
|
||||||
|
|
||||||
class ProfileSetupScreen extends ConsumerStatefulWidget {
|
class ProfileSetupScreen extends ConsumerStatefulWidget {
|
||||||
@@ -25,6 +26,8 @@ class _ProfileSetupScreenState extends ConsumerState<ProfileSetupScreen> {
|
|||||||
final _instagramController = TextEditingController();
|
final _instagramController = TextEditingController();
|
||||||
final _tiktokController = TextEditingController();
|
final _tiktokController = TextEditingController();
|
||||||
final _websiteController = TextEditingController();
|
final _websiteController = TextEditingController();
|
||||||
|
final _heightController = TextEditingController();
|
||||||
|
final _weightController = TextEditingController();
|
||||||
|
|
||||||
dynamic _avatarFile;
|
dynamic _avatarFile;
|
||||||
String? _avatarUrl;
|
String? _avatarUrl;
|
||||||
@@ -32,6 +35,11 @@ class _ProfileSetupScreenState extends ConsumerState<ProfileSetupScreen> {
|
|||||||
bool _isCheckingUsername = false;
|
bool _isCheckingUsername = false;
|
||||||
bool _isUsernameAvailable = true;
|
bool _isUsernameAvailable = true;
|
||||||
String? _usernameError;
|
String? _usernameError;
|
||||||
|
|
||||||
|
Gender? _selectedGender;
|
||||||
|
DateTime? _selectedBirthDate;
|
||||||
|
HeightUnit _selectedHeightUnit = HeightUnit.metric;
|
||||||
|
WeightUnit _selectedWeightUnit = WeightUnit.metric;
|
||||||
|
|
||||||
final ImagePicker _imagePicker = ImagePicker();
|
final ImagePicker _imagePicker = ImagePicker();
|
||||||
|
|
||||||
@@ -152,6 +160,18 @@ class _ProfileSetupScreenState extends ConsumerState<ProfileSetupScreen> {
|
|||||||
uploadedAvatarUrl = await _uploadAvatar();
|
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(
|
await ref.read(profileControllerProvider.notifier).completeProfileSetup(
|
||||||
userId: userId,
|
userId: userId,
|
||||||
username: _usernameController.text.trim(),
|
username: _usernameController.text.trim(),
|
||||||
@@ -169,6 +189,12 @@ class _ProfileSetupScreenState extends ConsumerState<ProfileSetupScreen> {
|
|||||||
websiteUrl: _websiteController.text.trim().isEmpty
|
websiteUrl: _websiteController.text.trim().isEmpty
|
||||||
? null
|
? null
|
||||||
: _websiteController.text.trim(),
|
: _websiteController.text.trim(),
|
||||||
|
gender: _selectedGender,
|
||||||
|
birthDate: _selectedBirthDate,
|
||||||
|
heightCm: heightCm,
|
||||||
|
weightKg: weightKg,
|
||||||
|
heightUnit: _selectedHeightUnit,
|
||||||
|
weightUnit: _selectedWeightUnit,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -195,6 +221,8 @@ class _ProfileSetupScreenState extends ConsumerState<ProfileSetupScreen> {
|
|||||||
_instagramController.dispose();
|
_instagramController.dispose();
|
||||||
_tiktokController.dispose();
|
_tiktokController.dispose();
|
||||||
_websiteController.dispose();
|
_websiteController.dispose();
|
||||||
|
_heightController.dispose();
|
||||||
|
_weightController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,6 +369,170 @@ class _ProfileSetupScreenState extends ConsumerState<ProfileSetupScreen> {
|
|||||||
),
|
),
|
||||||
enabled: !_isLoading,
|
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),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _twitterController,
|
controller: _twitterController,
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import '../../../bootstrap/supabase_client.dart';
|
|||||||
import '../../auth/application/auth_controller.dart';
|
import '../../auth/application/auth_controller.dart';
|
||||||
|
|
||||||
final userRepositoryProvider = Provider<UserRepository>((ref) {
|
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) {
|
final notificationsRepositoryProvider = Provider<NotificationsRepository>((ref) {
|
||||||
|
|||||||
@@ -202,13 +202,14 @@ class AboutChallengeScreen extends StatelessWidget {
|
|||||||
|
|
||||||
Future<void> _openLink(BuildContext context, String url) async {
|
Future<void> _openLink(BuildContext context, String url) async {
|
||||||
final uri = Uri.parse(url);
|
final uri = Uri.parse(url);
|
||||||
|
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
||||||
final launched = await launchUrl(
|
final launched = await launchUrl(
|
||||||
uri,
|
uri,
|
||||||
mode: LaunchMode.externalApplication,
|
mode: LaunchMode.externalApplication,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!launched) {
|
if (!launched) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
scaffoldMessenger.showSnackBar(
|
||||||
const SnackBar(content: Text('Could not open link')),
|
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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.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';
|
import '../../../core/widgets/app_scaffold.dart';
|
||||||
|
|
||||||
class SettingsHomeScreen extends ConsumerWidget {
|
class SettingsHomeScreen extends ConsumerWidget {
|
||||||
@@ -10,6 +10,7 @@ class SettingsHomeScreen extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return AppScaffold(
|
return AppScaffold(
|
||||||
|
title: 'Settings',
|
||||||
body: ListView(
|
body: ListView(
|
||||||
children: [
|
children: [
|
||||||
_buildSection(
|
_buildSection(
|
||||||
@@ -25,7 +26,7 @@ class SettingsHomeScreen extends ConsumerWidget {
|
|||||||
_SettingsTile(
|
_SettingsTile(
|
||||||
icon: Icons.email,
|
icon: Icons.email,
|
||||||
title: 'Email',
|
title: 'Email',
|
||||||
subtitle: supabase.Supabase.instance.client.auth.currentUser?.email ?? '',
|
subtitle: currentSupabaseUserEmail ?? 'Not signed in',
|
||||||
onTap: () => context.push('/settings/account'),
|
onTap: () => context.push('/settings/account'),
|
||||||
),
|
),
|
||||||
_SettingsTile(
|
_SettingsTile(
|
||||||
@@ -64,6 +65,12 @@ class SettingsHomeScreen extends ConsumerWidget {
|
|||||||
subtitle: 'Public or Private profile',
|
subtitle: 'Public or Private profile',
|
||||||
onTap: () => context.push('/settings/privacy'),
|
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(
|
_SettingsTile(
|
||||||
icon: Icons.block,
|
icon: Icons.block,
|
||||||
title: 'Blocked Users',
|
title: 'Blocked Users',
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ class SocialState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final socialRepositoryProvider = Provider<SocialRepository>((ref) {
|
final socialRepositoryProvider = Provider<SocialRepository>((ref) {
|
||||||
return SocialRepository(supabaseClient);
|
return SocialRepository(supabaseClient ?? (throw Exception("Supabase not initialized")));
|
||||||
});
|
});
|
||||||
|
|
||||||
final socialControllerProvider = StateNotifierProvider<SocialController, SocialState>((ref) {
|
final socialControllerProvider = StateNotifierProvider<SocialController, SocialState>((ref) {
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ final socialNotificationsControllerProvider =
|
|||||||
});
|
});
|
||||||
|
|
||||||
final socialRepositoryProvider = Provider<SocialRepository>((ref) {
|
final socialRepositoryProvider = Provider<SocialRepository>((ref) {
|
||||||
return SocialRepository(supabaseClient);
|
return SocialRepository(supabaseClient ?? (throw Exception("Supabase not initialized")));
|
||||||
});
|
});
|
||||||
|
|
||||||
final notificationsRepositoryProvider = Provider<NotificationsRepository>((ref) {
|
final notificationsRepositoryProvider = Provider<NotificationsRepository>((ref) {
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.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/app_scaffold.dart';
|
||||||
import '../../../core/widgets/loading_indicator.dart';
|
import '../../../core/widgets/loading_indicator.dart';
|
||||||
import '../../../core/utils/date_time_utils.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/user_model.dart' as app;
|
||||||
|
import '../../../data/models/goal_model.dart';
|
||||||
import '../../auth/application/auth_controller.dart';
|
import '../../auth/application/auth_controller.dart';
|
||||||
import '../application/social_controller.dart';
|
import '../application/social_controller.dart';
|
||||||
import '../../profile/application/profile_controller.dart';
|
import '../../profile/application/profile_controller.dart';
|
||||||
@@ -22,12 +25,19 @@ class PublicProfileScreen extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _PublicProfileScreenState extends ConsumerState<PublicProfileScreen> {
|
class _PublicProfileScreenState extends ConsumerState<PublicProfileScreen> {
|
||||||
|
Map<String, dynamic>? _userStats;
|
||||||
|
List<Goal>? _userGoals;
|
||||||
|
bool _isLoadingStats = false;
|
||||||
|
bool _isLoadingGoals = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_loadProfile();
|
_loadProfile();
|
||||||
_checkFollowingStatus();
|
_checkFollowingStatus();
|
||||||
|
_loadUserStats();
|
||||||
|
_loadUserGoals();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +49,37 @@ class _PublicProfileScreenState extends ConsumerState<PublicProfileScreen> {
|
|||||||
await ref.read(socialControllerProvider.notifier).isFollowing(widget.userId);
|
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 {
|
Future<void> _toggleFollow() async {
|
||||||
final controller = ref.read(socialControllerProvider.notifier);
|
final controller = ref.read(socialControllerProvider.notifier);
|
||||||
final isFollowing = await controller.isFollowing(widget.userId);
|
final isFollowing = await controller.isFollowing(widget.userId);
|
||||||
@@ -90,6 +131,8 @@ class _PublicProfileScreenState extends ConsumerState<PublicProfileScreen> {
|
|||||||
onRefresh: () async {
|
onRefresh: () async {
|
||||||
await _loadProfile();
|
await _loadProfile();
|
||||||
await _checkFollowingStatus();
|
await _checkFollowingStatus();
|
||||||
|
await _loadUserStats();
|
||||||
|
await _loadUserGoals();
|
||||||
},
|
},
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
@@ -101,7 +144,116 @@ class _PublicProfileScreenState extends ConsumerState<PublicProfileScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -109,6 +261,217 @@ class _PublicProfileScreenState extends ConsumerState<PublicProfileScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
class _ProfileHeader extends ConsumerWidget {
|
||||||
final app.User user;
|
final app.User user;
|
||||||
final bool isOwnProfile;
|
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 {
|
class _StatCard extends StatelessWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String title;
|
final String title;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ void main() async {
|
|||||||
const SystemUiOverlayStyle(
|
const SystemUiOverlayStyle(
|
||||||
statusBarColor: Colors.transparent,
|
statusBarColor: Colors.transparent,
|
||||||
systemNavigationBarColor: Colors.transparent,
|
systemNavigationBarColor: Colors.transparent,
|
||||||
|
systemNavigationBarDividerColor: Colors.transparent,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -42,7 +43,7 @@ class LifeTimerApp extends ConsumerWidget {
|
|||||||
final themeMode = ref.watch(themeModeProvider);
|
final themeMode = ref.watch(themeModeProvider);
|
||||||
|
|
||||||
return MaterialApp.router(
|
return MaterialApp.router(
|
||||||
title: 'LifeTimer',
|
title: '1356',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: AppTheme.light,
|
theme: AppTheme.light,
|
||||||
darkTheme: AppTheme.dark,
|
darkTheme: AppTheme.dark,
|
||||||
|
|||||||
+57
-1
@@ -427,6 +427,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.2.0"
|
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:
|
flutter_plugin_android_lifecycle:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -879,6 +887,46 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.0"
|
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:
|
logging:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -887,6 +935,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.0"
|
version: "1.3.0"
|
||||||
|
markdown:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: markdown
|
||||||
|
sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.3.0"
|
||||||
matcher:
|
matcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1397,7 +1453,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.12.1"
|
version: "1.12.1"
|
||||||
state_notifier:
|
state_notifier:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: state_notifier
|
name: state_notifier
|
||||||
sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
|
sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ dependencies:
|
|||||||
# State Management
|
# State Management
|
||||||
flutter_riverpod: ^2.4.9
|
flutter_riverpod: ^2.4.9
|
||||||
riverpod_annotation: ^2.3.3
|
riverpod_annotation: ^2.3.3
|
||||||
|
state_notifier: ^1.0.0
|
||||||
|
|
||||||
# Supabase Backend
|
# Supabase Backend
|
||||||
supabase_flutter: ^2.0.0
|
supabase_flutter: ^2.0.0
|
||||||
@@ -43,6 +44,7 @@ dependencies:
|
|||||||
http: ^1.1.0
|
http: ^1.1.0
|
||||||
url_launcher: ^6.1.10
|
url_launcher: ^6.1.10
|
||||||
home_widget: ^0.7.0
|
home_widget: ^0.7.0
|
||||||
|
local_auth: ^2.1.7
|
||||||
|
|
||||||
# Image Handling
|
# Image Handling
|
||||||
cached_network_image: ^3.3.0
|
cached_network_image: ^3.3.0
|
||||||
@@ -61,6 +63,7 @@ dependencies:
|
|||||||
# AI & Voice
|
# AI & Voice
|
||||||
record: ^6.1.2
|
record: ^6.1.2
|
||||||
permission_handler: ^11.0.1
|
permission_handler: ^11.0.1
|
||||||
|
flutter_markdown: ^0.7.3
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
mockito: ^5.4.4
|
mockito: ^5.4.4
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:lifetimer/features/auth/application/auth_controller.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_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/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/models/user_model.dart';
|
||||||
import 'package:lifetimer/data/repositories/auth_repository.dart';
|
import 'package:lifetimer/data/repositories/auth_repository.dart';
|
||||||
|
import 'package:lifetimer/core/utils/unit_conversion_utils.dart';
|
||||||
|
|
||||||
class MockAuthRepository extends AuthRepository {
|
class MockAuthRepository extends AuthRepository {
|
||||||
bool _isAuthenticated = false;
|
bool _isAuthenticated = false;
|
||||||
@@ -29,13 +31,14 @@ class MockAuthRepository extends AuthRepository {
|
|||||||
Future<void> signInWithEmail(String email, String password) async {}
|
Future<void> signInWithEmail(String email, String password) async {}
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
Future<void> signInWithGoogle() async {}
|
Future<void> signInWithGoogle() async {}
|
||||||
|
|
||||||
@override
|
Future<void> signInWithApple() async {
|
||||||
Future<void> signInWithApple() async {}
|
// Mock implementation
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> signOut() async {}
|
Future<void> signOut() async {}
|
||||||
@@ -55,12 +58,27 @@ class MockAuthRepository extends AuthRepository {
|
|||||||
String? bio,
|
String? bio,
|
||||||
String? avatarUrl,
|
String? avatarUrl,
|
||||||
bool? isPublicProfile,
|
bool? isPublicProfile,
|
||||||
|
double? heightCm,
|
||||||
|
double? weightKg,
|
||||||
|
int? age,
|
||||||
|
Gender? gender,
|
||||||
|
HeightUnit? heightUnit,
|
||||||
|
WeightUnit? weightUnit,
|
||||||
}) async {}
|
}) async {}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {}
|
void dispose() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class MockOnboardingController extends OnboardingController {
|
||||||
|
MockOnboardingController() : super();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> loadOnboardingStatus() async {
|
||||||
|
// Do nothing in test
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class TestData {
|
class TestData {
|
||||||
static User createTestUser() {
|
static User createTestUser() {
|
||||||
return User(
|
return User(
|
||||||
@@ -75,23 +93,28 @@ class TestData {
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('AuthGate Widget', () {
|
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 {
|
(WidgetTester tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
ProviderScope(
|
ProviderScope(
|
||||||
overrides: [
|
overrides: [
|
||||||
authRepositoryProvider.overrideWithValue(MockAuthRepository()),
|
authRepositoryProvider.overrideWithValue(MockAuthRepository()),
|
||||||
|
onboardingControllerProvider.overrideWith((ref) => MockOnboardingController()),
|
||||||
],
|
],
|
||||||
child: const MaterialApp(
|
child: MaterialApp(
|
||||||
home: AuthGate(),
|
home: const AuthGate(),
|
||||||
|
builder: (context, child) => MediaQuery(
|
||||||
|
data: const MediaQueryData(size: Size(800, 600)),
|
||||||
|
child: child!,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byType(AuthChoiceScreen), findsOneWidget);
|
expect(find.byType(OnboardingIntroScreen), findsOneWidget);
|
||||||
expect(find.byType(OnboardingIntroScreen), findsNothing);
|
expect(find.byType(AuthShowcaseScreen), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should show OnboardingIntroScreen when user is authenticated',
|
testWidgets('should show OnboardingIntroScreen when user is authenticated',
|
||||||
@@ -103,9 +126,14 @@ void main() {
|
|||||||
ProviderScope(
|
ProviderScope(
|
||||||
overrides: [
|
overrides: [
|
||||||
authRepositoryProvider.overrideWithValue(mockRepo),
|
authRepositoryProvider.overrideWithValue(mockRepo),
|
||||||
|
onboardingControllerProvider.overrideWith((ref) => MockOnboardingController()),
|
||||||
],
|
],
|
||||||
child: const MaterialApp(
|
child: MaterialApp(
|
||||||
home: AuthGate(),
|
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();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byType(OnboardingIntroScreen), findsOneWidget);
|
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 {
|
testWidgets('should validate username field', (WidgetTester tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
const ProviderScope(
|
ProviderScope(
|
||||||
child: const MaterialApp(
|
child: MaterialApp(
|
||||||
home: SignUpScreen(),
|
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();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Try to submit
|
// Try to submit
|
||||||
final signUpButton = find.text('Sign Up');
|
final signUpButton = find.text('Create Account');
|
||||||
await tester.tap(signUpButton);
|
await tester.tap(signUpButton);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
@@ -83,9 +87,13 @@ void main() {
|
|||||||
|
|
||||||
testWidgets('should validate email field', (WidgetTester tester) async {
|
testWidgets('should validate email field', (WidgetTester tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
const ProviderScope(
|
ProviderScope(
|
||||||
child: const MaterialApp(
|
child: MaterialApp(
|
||||||
home: SignUpScreen(),
|
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();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Try to submit
|
// Try to submit
|
||||||
final signUpButton = find.text('Sign Up');
|
final signUpButton = find.text('Create Account');
|
||||||
await tester.tap(signUpButton);
|
await tester.tap(signUpButton);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
@@ -108,9 +116,13 @@ void main() {
|
|||||||
|
|
||||||
testWidgets('should validate password field', (WidgetTester tester) async {
|
testWidgets('should validate password field', (WidgetTester tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
const ProviderScope(
|
ProviderScope(
|
||||||
child: const MaterialApp(
|
child: MaterialApp(
|
||||||
home: SignUpScreen(),
|
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();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Try to submit
|
// Try to submit
|
||||||
final signUpButton = find.text('Sign Up');
|
final signUpButton = find.text('Create Account');
|
||||||
await tester.tap(signUpButton);
|
await tester.tap(signUpButton);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
@@ -134,9 +146,13 @@ void main() {
|
|||||||
testWidgets('should toggle password visibility',
|
testWidgets('should toggle password visibility',
|
||||||
(WidgetTester tester) async {
|
(WidgetTester tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
const ProviderScope(
|
ProviderScope(
|
||||||
child: const MaterialApp(
|
child: MaterialApp(
|
||||||
home: SignUpScreen(),
|
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();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Find password visibility toggle button
|
// Find password visibility toggle button
|
||||||
final toggleButton = find.byIcon(Icons.visibility_off);
|
final toggleButton = find.byIcon(Icons.visibility_off_outlined);
|
||||||
expect(toggleButton, findsOneWidget);
|
expect(toggleButton, findsOneWidget);
|
||||||
|
|
||||||
await tester.tap(toggleButton);
|
await tester.tap(toggleButton);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Should now show visibility icon
|
// 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 {
|
(WidgetTester tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
const ProviderScope(
|
ProviderScope(
|
||||||
child: const MaterialApp(
|
child: MaterialApp(
|
||||||
home: SignUpScreen(),
|
home: const SignUpScreen(),
|
||||||
|
builder: (context, child) => MediaQuery(
|
||||||
|
data: const MediaQueryData(size: Size(400, 1200)), // Taller for form
|
||||||
|
child: child!,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.pumpAndSettle();
|
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 {
|
(WidgetTester tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
const ProviderScope(
|
ProviderScope(
|
||||||
child: const MaterialApp(
|
child: MaterialApp(
|
||||||
home: SignUpScreen(),
|
home: const SignUpScreen(),
|
||||||
|
builder: (context, child) => MediaQuery(
|
||||||
|
data: const MediaQueryData(size: Size(400, 1200)),
|
||||||
|
child: child!,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.pumpAndSettle();
|
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();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Your Journey Awaits'), findsOneWidget);
|
expect(find.text('Your Time is Now'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should display motivational message',
|
testWidgets('should display motivational message',
|
||||||
@@ -33,11 +33,11 @@ void main() {
|
|||||||
|
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.textContaining('goals'), findsOneWidget);
|
expect(find.textContaining('1356 days'), findsOneWidget);
|
||||||
expect(find.textContaining('dreams'), findsOneWidget);
|
expect(find.textContaining('dreams'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should display start challenge button',
|
testWidgets('should display get started button',
|
||||||
(WidgetTester tester) async {
|
(WidgetTester tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
const ProviderScope(
|
const ProviderScope(
|
||||||
@@ -49,7 +49,7 @@ void main() {
|
|||||||
|
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Start Your Challenge'), findsOneWidget);
|
expect(find.text('Get Started'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should display back button', (WidgetTester tester) async {
|
testWidgets('should display back button', (WidgetTester tester) async {
|
||||||
@@ -77,8 +77,8 @@ void main() {
|
|||||||
|
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Should have step indicators
|
expect(find.text('Step 3 of 3'), findsOneWidget);
|
||||||
expect(find.byType(Container), findsWidgets);
|
expect(find.byType(LinearProgressIndicator), findsOneWidget);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ void main() {
|
|||||||
expect(find.text('Profile'), findsOneWidget);
|
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(
|
await tester.pumpWidget(
|
||||||
const ProviderScope(
|
const ProviderScope(
|
||||||
child: const MaterialApp(
|
child: const MaterialApp(
|
||||||
@@ -32,10 +32,11 @@ void main() {
|
|||||||
|
|
||||||
await tester.pumpAndSettle();
|
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(
|
await tester.pumpWidget(
|
||||||
const ProviderScope(
|
const ProviderScope(
|
||||||
child: const MaterialApp(
|
child: const MaterialApp(
|
||||||
@@ -46,81 +47,7 @@ void main() {
|
|||||||
|
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Should display username section
|
expect(find.byIcon(Icons.person_off), findsOneWidget);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
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 {
|
(WidgetTester tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
const ProviderScope(
|
const ProviderScope(
|
||||||
@@ -90,7 +96,7 @@ void main() {
|
|||||||
|
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Account Settings'), findsOneWidget);
|
expect(find.text('Edit Profile'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should display notification settings option',
|
testWidgets('should display notification settings option',
|
||||||
@@ -108,7 +114,7 @@ void main() {
|
|||||||
expect(find.text('Notifications'), findsOneWidget);
|
expect(find.text('Notifications'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should display privacy settings option',
|
testWidgets('should display profile visibility option',
|
||||||
(WidgetTester tester) async {
|
(WidgetTester tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
const ProviderScope(
|
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();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Privacy Settings'), findsOneWidget);
|
expect(find.text('Profile Visibility'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should display about challenge option',
|
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();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('About the Challenge'), findsOneWidget);
|
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 'dart:async' as _i7;
|
||||||
|
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart'
|
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/activity_model.dart' as _i5;
|
||||||
import 'package:lifetimer/data/models/goal_model.dart' as _i2;
|
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/goal_step_model.dart' as _i3;
|
||||||
import 'package:lifetimer/data/models/user_model.dart' as _i4;
|
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/auth_repository.dart' as _i6;
|
||||||
import 'package:lifetimer/data/repositories/countdown_repository.dart' as _i10;
|
import 'package:lifetimer/data/repositories/countdown_repository.dart' as _i11;
|
||||||
import 'package:lifetimer/data/repositories/goals_repository.dart' as _i9;
|
import 'package:lifetimer/data/repositories/goals_repository.dart' as _i10;
|
||||||
import 'package:lifetimer/data/repositories/notifications_repository.dart'
|
import 'package:lifetimer/data/repositories/notifications_repository.dart'
|
||||||
as _i13;
|
as _i14;
|
||||||
import 'package:lifetimer/data/repositories/social_repository.dart' as _i12;
|
import 'package:lifetimer/data/repositories/social_repository.dart' as _i13;
|
||||||
import 'package:lifetimer/data/repositories/user_repository.dart' as _i11;
|
import 'package:lifetimer/data/repositories/user_repository.dart' as _i12;
|
||||||
import 'package:mockito/mockito.dart' as _i1;
|
import 'package:mockito/mockito.dart' as _i1;
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart' as _i8;
|
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(
|
_i7.Future<void> signUpWithEmail(
|
||||||
String? email,
|
String? email,
|
||||||
String? password,
|
String? password,
|
||||||
String? username,
|
String? username, {
|
||||||
) =>
|
double? heightCm,
|
||||||
|
double? weightKg,
|
||||||
|
int? age,
|
||||||
|
_i9.Gender? gender,
|
||||||
|
_i9.HeightUnit? heightUnit,
|
||||||
|
_i9.WeightUnit? weightUnit,
|
||||||
|
}) =>
|
||||||
(super.noSuchMethod(
|
(super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#signUpWithEmail,
|
#signUpWithEmail,
|
||||||
@@ -172,6 +179,14 @@ class MockAuthRepository extends _i1.Mock implements _i6.AuthRepository {
|
|||||||
password,
|
password,
|
||||||
username,
|
username,
|
||||||
],
|
],
|
||||||
|
{
|
||||||
|
#heightCm: heightCm,
|
||||||
|
#weightKg: weightKg,
|
||||||
|
#age: age,
|
||||||
|
#gender: gender,
|
||||||
|
#heightUnit: heightUnit,
|
||||||
|
#weightUnit: weightUnit,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
returnValue: _i7.Future<void>.value(),
|
returnValue: _i7.Future<void>.value(),
|
||||||
returnValueForMissingStub: _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(),
|
returnValueForMissingStub: _i7.Future<void>.value(),
|
||||||
) as _i7.Future<void>);
|
) 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
|
@override
|
||||||
_i7.Future<void> signOut() => (super.noSuchMethod(
|
_i7.Future<void> signOut() => (super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
@@ -233,6 +238,12 @@ class MockAuthRepository extends _i1.Mock implements _i6.AuthRepository {
|
|||||||
String? bio,
|
String? bio,
|
||||||
String? avatarUrl,
|
String? avatarUrl,
|
||||||
bool? isPublicProfile,
|
bool? isPublicProfile,
|
||||||
|
double? heightCm,
|
||||||
|
double? weightKg,
|
||||||
|
int? age,
|
||||||
|
_i9.Gender? gender,
|
||||||
|
_i9.HeightUnit? heightUnit,
|
||||||
|
_i9.WeightUnit? weightUnit,
|
||||||
}) =>
|
}) =>
|
||||||
(super.noSuchMethod(
|
(super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
@@ -243,6 +254,12 @@ class MockAuthRepository extends _i1.Mock implements _i6.AuthRepository {
|
|||||||
#bio: bio,
|
#bio: bio,
|
||||||
#avatarUrl: avatarUrl,
|
#avatarUrl: avatarUrl,
|
||||||
#isPublicProfile: isPublicProfile,
|
#isPublicProfile: isPublicProfile,
|
||||||
|
#heightCm: heightCm,
|
||||||
|
#weightKg: weightKg,
|
||||||
|
#age: age,
|
||||||
|
#gender: gender,
|
||||||
|
#heightUnit: heightUnit,
|
||||||
|
#weightUnit: weightUnit,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
returnValue: _i7.Future<void>.value(),
|
returnValue: _i7.Future<void>.value(),
|
||||||
@@ -253,7 +270,7 @@ class MockAuthRepository extends _i1.Mock implements _i6.AuthRepository {
|
|||||||
/// A class which mocks [GoalsRepository].
|
/// A class which mocks [GoalsRepository].
|
||||||
///
|
///
|
||||||
/// See the documentation for Mockito's code generation for more information.
|
/// 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() {
|
MockGoalsRepository() {
|
||||||
_i1.throwOnMissingStub(this);
|
_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.
|
/// See the documentation for Mockito's code generation for more information.
|
||||||
class MockCountdownRepository extends _i1.Mock
|
class MockCountdownRepository extends _i1.Mock
|
||||||
implements _i10.CountdownRepository {
|
implements _i11.CountdownRepository {
|
||||||
MockCountdownRepository() {
|
MockCountdownRepository() {
|
||||||
_i1.throwOnMissingStub(this);
|
_i1.throwOnMissingStub(this);
|
||||||
}
|
}
|
||||||
@@ -445,7 +462,7 @@ class MockCountdownRepository extends _i1.Mock
|
|||||||
/// A class which mocks [UserRepository].
|
/// A class which mocks [UserRepository].
|
||||||
///
|
///
|
||||||
/// See the documentation for Mockito's code generation for more information.
|
/// 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() {
|
MockUserRepository() {
|
||||||
_i1.throwOnMissingStub(this);
|
_i1.throwOnMissingStub(this);
|
||||||
}
|
}
|
||||||
@@ -476,6 +493,12 @@ class MockUserRepository extends _i1.Mock implements _i11.UserRepository {
|
|||||||
String? instagramHandle,
|
String? instagramHandle,
|
||||||
String? tiktokHandle,
|
String? tiktokHandle,
|
||||||
String? websiteUrl,
|
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(
|
(super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
@@ -491,6 +514,12 @@ class MockUserRepository extends _i1.Mock implements _i11.UserRepository {
|
|||||||
#instagramHandle: instagramHandle,
|
#instagramHandle: instagramHandle,
|
||||||
#tiktokHandle: tiktokHandle,
|
#tiktokHandle: tiktokHandle,
|
||||||
#websiteUrl: websiteUrl,
|
#websiteUrl: websiteUrl,
|
||||||
|
#gender: gender,
|
||||||
|
#birthDate: birthDate,
|
||||||
|
#heightCm: heightCm,
|
||||||
|
#weightKg: weightKg,
|
||||||
|
#heightUnit: heightUnit,
|
||||||
|
#weightUnit: weightUnit,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
returnValue: _i7.Future<_i4.User>.value(_FakeUser_2(
|
returnValue: _i7.Future<_i4.User>.value(_FakeUser_2(
|
||||||
@@ -508,6 +537,12 @@ class MockUserRepository extends _i1.Mock implements _i11.UserRepository {
|
|||||||
#instagramHandle: instagramHandle,
|
#instagramHandle: instagramHandle,
|
||||||
#tiktokHandle: tiktokHandle,
|
#tiktokHandle: tiktokHandle,
|
||||||
#websiteUrl: websiteUrl,
|
#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].
|
/// A class which mocks [SocialRepository].
|
||||||
///
|
///
|
||||||
/// See the documentation for Mockito's code generation for more information.
|
/// 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() {
|
MockSocialRepository() {
|
||||||
_i1.throwOnMissingStub(this);
|
_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.
|
/// See the documentation for Mockito's code generation for more information.
|
||||||
class MockNotificationsRepository extends _i1.Mock
|
class MockNotificationsRepository extends _i1.Mock
|
||||||
implements _i13.NotificationsRepository {
|
implements _i14.NotificationsRepository {
|
||||||
MockNotificationsRepository() {
|
MockNotificationsRepository() {
|
||||||
_i1.throwOnMissingStub(this);
|
_i1.throwOnMissingStub(this);
|
||||||
}
|
}
|
||||||
@@ -769,13 +804,13 @@ class MockNotificationsRepository extends _i1.Mock
|
|||||||
) as _i7.Future<void>);
|
) as _i7.Future<void>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i7.Future<List<_i14.PendingNotificationRequest>> getPendingNotifications() =>
|
_i7.Future<List<_i15.PendingNotificationRequest>> getPendingNotifications() =>
|
||||||
(super.noSuchMethod(
|
(super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#getPendingNotifications,
|
#getPendingNotifications,
|
||||||
[],
|
[],
|
||||||
),
|
),
|
||||||
returnValue: _i7.Future<List<_i14.PendingNotificationRequest>>.value(
|
returnValue: _i7.Future<List<_i15.PendingNotificationRequest>>.value(
|
||||||
<_i14.PendingNotificationRequest>[]),
|
<_i15.PendingNotificationRequest>[]),
|
||||||
) as _i7.Future<List<_i14.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