3 Commits

Author SHA1 Message Date
Tomas Dvorak 5ab2773f98 small fix, don't worry about it 2026-04-10 12:05:40 +02:00
Tomas Dvorak 7b7ed0083f Merge branch 'master' of https://github.com/Dvorinka/1356 2026-01-06 13:46:49 +01:00
Tomas Dvorak 2aa4c0721f fix and improve 2026-01-05 18:23:21 +01:00
60 changed files with 3612 additions and 667 deletions
+121
View File
@@ -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">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<application
android:label="lifetimer"
android:label="1356"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
@@ -16,7 +16,7 @@ class NextCountdownWidgetProvider : HomeWidgetProvider() {
) {
appWidgetIds.forEach { widgetId ->
val title = widgetData.getString("next_title", "Next goal")
val subtitle = widgetData.getString("next_subtitle", "Open Lifetimer to see details")
val subtitle = widgetData.getString("next_subtitle", "Open 1356 to see details")
val timeLeft = widgetData.getString("next_time_left", "0 days left")
val views = RemoteViews(context.packageName, R.layout.next_countdown_widget).apply {
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#1E1E1E" />
<corners android:radius="16dp" />
<stroke
android:width="1dp"
android:color="#33FFFFFF" />
</shape>
@@ -2,7 +2,8 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#121212"
android:background="#1A1A1A"
android:backgroundTint="#2A2A2A"
android:gravity="center_vertical"
android:orientation="vertical"
android:padding="12dp">
@@ -13,6 +14,7 @@
android:layout_height="wrap_content"
android:text="Next goal"
android:textColor="#FFFFFF"
android:textStyle="bold"
android:textSize="16sp"
android:maxLines="1"
android:ellipsize="end" />
@@ -23,7 +25,8 @@
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="0 days left"
android:textColor="#FFCC66"
android:textColor="#4CAF50"
android:textStyle="bold"
android:textSize="20sp"
android:maxLines="1"
android:ellipsize="end" />
@@ -33,7 +36,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="Open Lifetimer to see details"
android:text="Open 1356 to see details"
android:textColor="#B3FFFFFF"
android:textSize="12sp"
android:maxLines="2"
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_background"
android:gravity="center"
android:orientation="vertical"
android:padding="16dp">
<!-- 1356 Branding -->
<TextView
android:id="@+id/text_brand"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="1356"
android:textColor="#FFFFFF"
android:textSize="12sp"
android:textStyle="bold"
android:background="#4CAF50"
android:paddingHorizontal="8dp"
android:paddingVertical="4dp"
android:layout_marginBottom="12dp" />
<!-- Days Remaining -->
<TextView
android:id="@+id/text_days"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="1356"
android:textColor="#FFFFFF"
android:textSize="36sp"
android:textStyle="bold"
android:layout_marginBottom="4dp" />
<!-- Label -->
<TextView
android:id="@+id/text_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="days remaining"
android:textColor="#B3FFFFFF"
android:textSize="12sp"
android:layout_marginBottom="16dp" />
<!-- Progress Bar -->
<ProgressBar
android:id="@+id/progress_bar"
android:layout_width="match_parent"
android:layout_height="4dp"
android:layout_marginBottom="8dp"
style="?android:attr/progressBarStyleHorizontal"
android:progressTint="#4CAF50"
android:progressBackgroundTint="#33FFFFFF" />
<!-- Progress Text -->
<TextView
android:id="@+id/text_progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0% Complete"
android:textColor="#B3FFFFFF"
android:textSize="10sp" />
</LinearLayout>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">1356</string>
<!-- Google Sign-In Configuration -->
<string name="default_web_client_id" translatable="false">YOUR_WEB_CLIENT_ID_HERE</string>
</resources>
+3 -3
View File
@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Lifetimer</string>
<string>1356</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -13,7 +13,7 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>lifetimer</string>
<string>1356</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
@@ -52,7 +52,7 @@
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>lifetimer</string>
<string>1356</string>
</array>
</dict>
<dict>
+15 -1
View File
@@ -11,11 +11,25 @@ Future<void> bootstrap() async {
await HomeWidget.setAppGroupId(Env.iosAppGroupId);
}
// Only initialize Supabase if we have valid credentials
if (Env.supabaseUrl.isNotEmpty &&
Env.supabaseUrl != 'https://your-project.supabase.co' &&
Env.supabaseAnonKey.isNotEmpty &&
Env.supabaseAnonKey != 'your-anon-key') {
try {
await Supabase.initialize(
url: Env.supabaseUrl,
anonKey: Env.supabaseAnonKey,
debug: true,
);
initializeSupabaseClient();
} catch (e) {
// If Supabase initialization fails, continue without it
print('Warning: Supabase initialization failed: $e');
print('App will run in offline mode');
}
} else {
print('Warning: Valid Supabase credentials not provided');
print('App will run in offline mode');
}
}
+37 -6
View File
@@ -1,17 +1,35 @@
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
void initializeSupabaseClient() {
// Additional client setup if needed
// For now, we use the default Supabase.instance.client
}
SupabaseClient get supabaseClient => Supabase.instance.client;
supabase.SupabaseClient? get supabaseClient {
try {
return supabase.Supabase.instance.client;
} catch (e) {
// Supabase not initialized
return null;
}
}
bool get isSupabaseInitialized => supabaseClient != null;
supabase.User? get currentSupabaseUser {
final client = supabaseClient;
return client?.auth.currentUser;
}
String? get currentSupabaseUserId => currentSupabaseUser?.id;
String? get currentSupabaseUserEmail => currentSupabaseUser?.email;
// Service role client for admin operations (like creating user profiles)
// This should be used server-side or with proper security measures
SupabaseClient? _serviceRoleClient;
supabase.SupabaseClient? _serviceRoleClient;
SupabaseClient getServiceRoleClient() {
supabase.SupabaseClient getServiceRoleClient() {
if (_serviceRoleClient != null) return _serviceRoleClient!;
// 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');
if (serviceRoleKey.isNotEmpty && url.isNotEmpty) {
_serviceRoleClient = SupabaseClient(url, serviceRoleKey);
_serviceRoleClient = supabase.SupabaseClient(url, serviceRoleKey);
return _serviceRoleClient!;
}
} catch (e) {
// 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';
part 'cached_goal.g.dart';
part 'cached_goal_model.g.dart';
@HiveType(typeId: 0)
class CachedGoal extends HiveObject {
@@ -1,5 +1,11 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'cached_goal_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class CachedGoalAdapter extends TypeAdapter<CachedGoal> {
@override
final int typeId = 0;
@@ -29,33 +35,34 @@ class CachedGoalAdapter extends TypeAdapter<CachedGoal> {
@override
void write(BinaryWriter writer, CachedGoal obj) {
writer.writeByte(13);
writer.writeByte(0);
writer.write(obj.id);
writer.writeByte(1);
writer.write(obj.ownerId);
writer.writeByte(2);
writer.write(obj.title);
writer.writeByte(3);
writer.write(obj.description);
writer.writeByte(4);
writer.write(obj.progress);
writer.writeByte(5);
writer.write(obj.locationLat);
writer.writeByte(6);
writer.write(obj.locationLng);
writer.writeByte(7);
writer.write(obj.locationName);
writer.writeByte(8);
writer.write(obj.imageUrl);
writer.writeByte(9);
writer.write(obj.completed);
writer.writeByte(10);
writer.write(obj.createdAt);
writer.writeByte(11);
writer.write(obj.updatedAt);
writer.writeByte(12);
writer.write(obj.isDirty);
writer
..writeByte(13)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.ownerId)
..writeByte(2)
..write(obj.title)
..writeByte(3)
..write(obj.description)
..writeByte(4)
..write(obj.progress)
..writeByte(5)
..write(obj.locationLat)
..writeByte(6)
..write(obj.locationLng)
..writeByte(7)
..write(obj.locationName)
..writeByte(8)
..write(obj.imageUrl)
..writeByte(9)
..write(obj.completed)
..writeByte(10)
..write(obj.createdAt)
..writeByte(11)
..write(obj.updatedAt)
..writeByte(12)
..write(obj.isDirty);
}
@override
@@ -64,5 +71,7 @@ class CachedGoalAdapter extends TypeAdapter<CachedGoal> {
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is CachedGoalAdapter && runtimeType == other.runtimeType && typeId == other.typeId;
other is CachedGoalAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
+79
View File
@@ -1,4 +1,5 @@
import 'package:equatable/equatable.dart';
import '../../core/utils/unit_conversion_utils.dart';
class User extends Equatable {
final String id;
@@ -13,6 +14,13 @@ class User extends Equatable {
final String? websiteUrl;
final DateTime? countdownStartDate;
final DateTime? countdownEndDate;
final Gender? gender;
final DateTime? birthDate;
final int? storedAge;
final double? heightCm;
final double? weightKg;
final HeightUnit heightUnit;
final WeightUnit weightUnit;
final DateTime createdAt;
final DateTime updatedAt;
@@ -29,6 +37,13 @@ class User extends Equatable {
this.websiteUrl,
this.countdownStartDate,
this.countdownEndDate,
this.gender,
this.birthDate,
this.storedAge,
this.heightCm,
this.weightKg,
this.heightUnit = HeightUnit.metric,
this.weightUnit = WeightUnit.metric,
required this.createdAt,
required this.updatedAt,
});
@@ -45,6 +60,33 @@ class User extends Equatable {
return countdownEndDate!.difference(DateTime.now()).inDays;
}
int? get age {
if (storedAge != null) return storedAge;
if (birthDate == null) return null;
return UnitConversionUtils.calculateAge(birthDate!);
}
String get formattedHeight {
if (heightCm == null) return '';
return UnitConversionUtils.formatHeight(heightCm!, heightUnit);
}
String get formattedWeight {
if (weightKg == null) return '';
return UnitConversionUtils.formatWeight(weightKg!, weightUnit);
}
double? get bmi {
if (heightCm == null || weightKg == null) return null;
return UnitConversionUtils.calculateBmi(weightKg!, heightCm!);
}
String get bmiCategory {
final bmiValue = bmi;
if (bmiValue == null) return '';
return UnitConversionUtils.getBmiCategory(bmiValue);
}
User copyWith({
String? id,
String? username,
@@ -58,6 +100,13 @@ class User extends Equatable {
String? websiteUrl,
DateTime? countdownStartDate,
DateTime? countdownEndDate,
Gender? gender,
DateTime? birthDate,
int? storedAge,
double? heightCm,
double? weightKg,
HeightUnit? heightUnit,
WeightUnit? weightUnit,
DateTime? createdAt,
DateTime? updatedAt,
}) {
@@ -74,6 +123,13 @@ class User extends Equatable {
websiteUrl: websiteUrl ?? this.websiteUrl,
countdownStartDate: countdownStartDate ?? this.countdownStartDate,
countdownEndDate: countdownEndDate ?? this.countdownEndDate,
gender: gender ?? this.gender,
birthDate: birthDate ?? this.birthDate,
storedAge: storedAge ?? this.storedAge,
heightCm: heightCm ?? this.heightCm,
weightKg: weightKg ?? this.weightKg,
heightUnit: heightUnit ?? this.heightUnit,
weightUnit: weightUnit ?? this.weightUnit,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
@@ -93,6 +149,13 @@ class User extends Equatable {
websiteUrl,
countdownStartDate,
countdownEndDate,
gender,
birthDate,
storedAge,
heightCm,
weightKg,
heightUnit,
weightUnit,
createdAt,
updatedAt,
];
@@ -111,6 +174,13 @@ class User extends Equatable {
'website_url': websiteUrl,
'countdown_start_date': countdownStartDate?.toIso8601String(),
'countdown_end_date': countdownEndDate?.toIso8601String(),
'gender': gender?.toDatabaseString(),
'birth_date': birthDate?.toIso8601String().split('T').first,
'age': storedAge,
'height_cm': heightCm,
'weight_kg': weightKg,
'height_unit': heightUnit.code,
'weight_unit': weightUnit.code,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
};
@@ -134,6 +204,15 @@ class User extends Equatable {
countdownEndDate: json['countdown_end_date'] != null
? DateTime.parse(json['countdown_end_date'] as String)
: null,
gender: json['gender'] != null ? Gender.fromString(json['gender'] as String) : null,
birthDate: json['birth_date'] != null ? DateTime.parse(json['birth_date'] as String) : null,
storedAge: json['age'] as int?,
heightCm: json['height_cm'] as double?,
weightKg: json['weight_kg'] as double?,
heightUnit: json['height_unit'] != null ?
HeightUnit.values.firstWhere((unit) => unit.code == json['height_unit']) : HeightUnit.metric,
weightUnit: json['weight_unit'] != null ?
WeightUnit.values.firstWhere((unit) => unit.code == json['weight_unit']) : WeightUnit.metric,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
);
@@ -1,17 +1,23 @@
import 'dart:async';
import '../models/user_model.dart';
import '../../bootstrap/supabase_client.dart';
import '../../core/utils/unit_conversion_utils.dart';
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
import 'package:google_sign_in/google_sign_in.dart';
class AuthRepository {
final supabase.SupabaseClient _client;
final supabase.SupabaseClient? _client;
StreamSubscription<supabase.AuthState>? _authStateSubscription;
AuthRepository([supabase.SupabaseClient? client]) : _client = client ?? supabaseClient;
AuthRepository([supabase.SupabaseClient? client]) : _client = client;
Stream<User?> get authStateChanges {
return _client.auth.onAuthStateChange.map((data) {
final client = supabaseClient;
if (client == null) {
// Return a stream that never emits if Supabase is not initialized
return Stream.empty();
}
return client.auth.onAuthStateChange.map((data) {
final session = data.session;
if (session?.user != null) {
return _mapSupabaseUserToAppUser(session!.user);
@@ -21,16 +27,27 @@ class AuthRepository {
}
User? get currentUser {
final user = _client.auth.currentUser;
final client = supabaseClient;
if (client == null) return null;
final user = client.auth.currentUser;
return user != null ? _mapSupabaseUserToAppUser(user) : null;
}
bool get isAuthenticated => _client.auth.currentUser != null;
bool get isAuthenticated {
final client = supabaseClient;
if (client == null) return false;
return client.auth.currentUser != null;
}
String? get currentUserId => _client.auth.currentUser?.id;
String? get currentUserId {
final client = supabaseClient;
if (client == null) return null;
return client.auth.currentUser?.id;
}
Future<bool> isSessionValid() async {
final session = _client.auth.currentSession;
assert(_client != null, 'Client must not be null');
final session = _client!.auth.currentSession;
if (session == null) return false;
final now = DateTime.now();
@@ -41,19 +58,22 @@ class AuthRepository {
}
Future<void> refreshSession() async {
assert(_client != null, 'Client must not be null');
try {
await _client.auth.refreshSession();
await _client!.auth.refreshSession();
} catch (e) {
throw Exception('Failed to refresh session: $e');
}
}
Future<supabase.Session?> getCurrentSession() async {
return _client.auth.currentSession;
assert(_client != null, 'Client must not be null');
return _client!.auth.currentSession;
}
void listenToAuthStateChanges(Function(User?) callback) {
_authStateSubscription = _client.auth.onAuthStateChange.listen((data) {
assert(_client != null, 'Client must not be null');
_authStateSubscription = _client!.auth.onAuthStateChange.listen((data) {
final session = data.session;
if (session?.user != null) {
callback(_mapSupabaseUserToAppUser(session!.user));
@@ -68,58 +88,89 @@ class AuthRepository {
}
Future<void> signInWithEmail(String email, String password) async {
await _client.auth.signInWithPassword(email: email, password: password);
assert(_client != null, 'Client must not be null');
await _client!.auth.signInWithPassword(email: email, password: password);
}
Future<void> signUpWithEmail(String email, String password, String username) async {
final response = await _client.auth.signUp(
Future<void> signUpWithEmail(String email, String password, String username, {double? heightCm, double? weightKg, int? age, Gender? gender, HeightUnit? heightUnit, WeightUnit? weightUnit}) async {
assert(_client != null, 'Client must not be null');
final response = await _client!.auth.signUp(
email: email,
password: password,
data: {'username': username},
);
if (response.user != null) {
await _createUserProfile(response.user!.id, username, email);
await _createUserProfile(response.user!.id, username, email, heightCm: heightCm, weightKg: weightKg, age: age, gender: gender, heightUnit: heightUnit, weightUnit: weightUnit);
}
}
Future<void> signInWithGoogle() async {
final GoogleSignIn googleSignIn = GoogleSignIn();
assert(_client != null, 'Client must not be null');
try {
final GoogleSignIn googleSignIn = GoogleSignIn(
scopes: ['email', 'profile'],
);
final googleUser = await googleSignIn.signIn();
if (googleUser == null) {
// Check if user is already signed in
final googleUser = await googleSignIn.signInSilently();
if (googleUser != null) {
await _handleGoogleUser(googleUser);
return;
}
// Sign in interactively
final interactiveUser = await googleSignIn.signIn();
if (interactiveUser == null) {
throw Exception('Google sign-in was cancelled');
}
final googleAuth = await googleUser.authentication;
final idToken = googleAuth.idToken;
if (idToken == null) {
throw Exception('No ID token from Google sign-in');
await _handleGoogleUser(interactiveUser);
} catch (e) {
throw Exception('Google sign-in failed: ${e.toString()}');
}
}
final response = await _client.auth.signInWithIdToken(
Future<void> _handleGoogleUser(dynamic googleUser) async {
assert(_client != null, 'Client must not be null');
try {
final googleAuth = await googleUser.authentication;
final idToken = googleAuth.idToken;
final accessToken = googleAuth.accessToken;
if (idToken == null && accessToken == null) {
throw Exception('No ID token or access token from Google sign-in');
}
final response = await _client!.auth.signInWithIdToken(
provider: supabase.OAuthProvider.google,
idToken: idToken,
accessToken: accessToken,
);
if (response.user != null) {
await _ensureUserProfileExists(response.user!.id, response.user!);
}
} catch (e) {
throw Exception('Failed to authenticate with Google: ${e.toString()}');
}
}
Future<void> signInWithGithub() async {
await _client.auth.signInWithOAuth(
assert(_client != null, 'Client must not be null');
await _client!.auth.signInWithOAuth(
supabase.OAuthProvider.github,
);
}
Future<void> signOut() async {
await _client.auth.signOut();
assert(_client != null, 'Client must not be null');
await _client!.auth.signOut();
}
Future<void> resetPassword(String email) async {
await _client.auth.resetPasswordForEmail(email);
assert(_client != null, 'Client must not be null');
await _client!.auth.resetPasswordForEmail(email);
}
Future<void> updateProfile({
@@ -127,8 +178,15 @@ class AuthRepository {
String? bio,
String? avatarUrl,
bool? isPublicProfile,
double? heightCm,
double? weightKg,
int? age,
Gender? gender,
HeightUnit? heightUnit,
WeightUnit? weightUnit,
}) async {
final userId = _client.auth.currentUser?.id;
assert(_client != null, 'Client must not be null');
final userId = _client!.auth.currentUser?.id;
if (userId == null) throw Exception('User not authenticated');
final updates = <String, dynamic>{};
@@ -136,23 +194,36 @@ class AuthRepository {
if (bio != null) updates['bio'] = bio;
if (avatarUrl != null) updates['avatar_url'] = avatarUrl;
if (isPublicProfile != null) updates['is_public_profile'] = isPublicProfile;
if (heightCm != null) updates['height_cm'] = heightCm;
if (weightKg != null) updates['weight_kg'] = weightKg;
if (age != null) updates['age'] = age;
if (gender != null) updates['gender'] = gender.toDatabaseString();
if (heightUnit != null) updates['height_unit'] = heightUnit.code;
if (weightUnit != null) updates['weight_unit'] = weightUnit.code;
updates['updated_at'] = DateTime.now().toIso8601String();
await _client
await _client!
.from('users')
.update(updates)
.eq('id', userId);
}
Future<User> _createUserProfile(String userId, String username, String email) async {
Future<User> _createUserProfile(String userId, String username, String email, {double? heightCm, double? weightKg, int? age, Gender? gender, HeightUnit? heightUnit, WeightUnit? weightUnit}) async {
assert(_client != null, 'Client must not be null');
final now = DateTime.now().toIso8601String();
try {
// 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,
'username': username,
'email': email,
'height_cm': heightCm,
'weight_kg': weightKg,
'age': age,
'gender': gender?.toDatabaseString(),
'height_unit': heightUnit?.code ?? HeightUnit.metric.code,
'weight_unit': weightUnit?.code ?? WeightUnit.metric.code,
'created_at': now,
'updated_at': now,
}).select();
@@ -168,6 +239,12 @@ class AuthRepository {
'id': userId,
'username': username,
'email': email,
'height_cm': heightCm,
'weight_kg': weightKg,
'age': age,
'gender': gender?.toDatabaseString(),
'height_unit': heightUnit?.code ?? HeightUnit.metric.code,
'weight_unit': weightUnit?.code ?? WeightUnit.metric.code,
'created_at': now,
'updated_at': now,
}).select();
@@ -182,6 +259,12 @@ class AuthRepository {
id: userId,
username: username,
email: email,
storedAge: age,
heightCm: heightCm,
weightKg: weightKg,
gender: gender,
heightUnit: heightUnit ?? HeightUnit.metric,
weightUnit: weightUnit ?? WeightUnit.metric,
createdAt: DateTime.parse(now),
updatedAt: DateTime.parse(now),
);
@@ -193,14 +276,21 @@ class AuthRepository {
id: userId,
username: username,
email: email,
storedAge: age,
heightCm: heightCm,
weightKg: weightKg,
gender: gender,
heightUnit: heightUnit ?? HeightUnit.metric,
weightUnit: weightUnit ?? WeightUnit.metric,
createdAt: DateTime.parse(now),
updatedAt: DateTime.parse(now),
);
}
Future<void> _ensureUserProfileExists(String userId, dynamic supabaseUser) async {
assert(_client != null, 'Client must not be null');
try {
final existingProfile = await _client
final existingProfile = await _client!
.from('users')
.select('id')
.eq('id', userId)
@@ -246,6 +336,10 @@ class AuthRepository {
countdownEndDate: data['countdown_end_date'] != null
? DateTime.parse(data['countdown_end_date'])
: null,
gender: data['gender'] != null ? Gender.fromString(data['gender']) : null,
storedAge: data['age'] as int?,
heightCm: (data['height_cm'] as num?)?.toDouble(),
weightKg: (data['weight_kg'] as num?)?.toDouble(),
createdAt: DateTime.parse(data['created_at']),
updatedAt: DateTime.parse(data['updated_at']),
);
@@ -1,6 +1,7 @@
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
import '../models/user_model.dart' as app;
import '../../core/errors/failure.dart';
import '../../core/utils/unit_conversion_utils.dart';
class UserRepository {
final supabase.SupabaseClient _client;
@@ -35,6 +36,12 @@ class UserRepository {
String? instagramHandle,
String? tiktokHandle,
String? websiteUrl,
Gender? gender,
DateTime? birthDate,
double? heightCm,
double? weightKg,
HeightUnit heightUnit = HeightUnit.metric,
WeightUnit weightUnit = WeightUnit.metric,
}) async {
try {
final updates = <String, dynamic>{};
@@ -46,6 +53,12 @@ class UserRepository {
if (instagramHandle != null) updates['instagram_handle'] = instagramHandle;
if (tiktokHandle != null) updates['tiktok_handle'] = tiktokHandle;
if (websiteUrl != null) updates['website_url'] = websiteUrl;
if (gender != null) updates['gender'] = gender.toDatabaseString();
if (birthDate != null) updates['birth_date'] = birthDate.toIso8601String().split('T').first;
if (heightCm != null) updates['height_cm'] = heightCm;
if (weightKg != null) updates['weight_kg'] = weightKg;
updates['height_unit'] = heightUnit.code;
updates['weight_unit'] = weightUnit.code;
updates['updated_at'] = DateTime.now().toIso8601String();
final response = await _client
@@ -0,0 +1,275 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:local_auth/local_auth.dart';
import 'package:shared_preferences/shared_preferences.dart';
enum BiometricAvailability {
available,
notAvailable,
notEnrolled,
lockedOut,
permanentlyUnavailable;
String get message {
switch (this) {
case BiometricAvailability.available:
return 'Biometric authentication is available';
case BiometricAvailability.notAvailable:
return 'Biometric authentication is not available on this device';
case BiometricAvailability.notEnrolled:
return 'No biometric credentials enrolled on this device';
case BiometricAvailability.lockedOut:
return 'Biometric authentication is temporarily locked out';
case BiometricAvailability.permanentlyUnavailable:
return 'Biometric authentication is permanently unavailable';
}
}
}
class BiometricService {
static const String _biometricEnabledKey = 'biometric_enabled';
static const String _biometricUserIdKey = 'biometric_user_id';
final LocalAuthentication _localAuth = LocalAuthentication();
/// Get display name for biometric type
String getBiometricDisplayName(BiometricType type) {
switch (type) {
case BiometricType.fingerprint:
return 'Fingerprint';
case BiometricType.face:
return 'Face ID';
case BiometricType.iris:
return 'Iris Scanner';
default:
return 'Biometric';
}
}
/// Get emoji for biometric type
String getBiometricEmoji(BiometricType type) {
switch (type) {
case BiometricType.fingerprint:
return '👆';
case BiometricType.face:
return '👤';
case BiometricType.iris:
return '👁️';
default:
return '🔒';
}
}
/// Check if biometric authentication is available
Future<BiometricAvailability> checkAvailability() async {
try {
// Check if device supports biometric authentication
final canCheckBiometrics = await _localAuth.canCheckBiometrics;
if (!canCheckBiometrics) {
return BiometricAvailability.notAvailable;
}
// Check if biometric credentials are enrolled
final isDeviceSupported = await _localAuth.isDeviceSupported();
if (!isDeviceSupported) {
return BiometricAvailability.notAvailable;
}
// Try to get available biometric types
final availableBiometrics = await _localAuth.getAvailableBiometrics();
if (availableBiometrics.isEmpty) {
return BiometricAvailability.notEnrolled;
}
return BiometricAvailability.available;
} on PlatformException catch (e) {
if (e.code == 'LockedOut') {
return BiometricAvailability.lockedOut;
} else if (e.code == 'PermanentlyEnrolled') {
return BiometricAvailability.permanentlyUnavailable;
} else if (e.code == 'NotAvailable') {
return BiometricAvailability.notAvailable;
} else if (e.code == 'NotEnrolled') {
return BiometricAvailability.notEnrolled;
}
return BiometricAvailability.notAvailable;
} catch (e) {
return BiometricAvailability.notAvailable;
}
}
/// Get available biometric types
Future<List<BiometricType>> getAvailableBiometrics() async {
try {
final availableBiometrics = await _localAuth.getAvailableBiometrics();
return availableBiometrics.toList();
} catch (e) {
return [];
}
}
/// Check if biometric login is enabled for a user
Future<bool> isBiometricEnabled() async {
try {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_biometricEnabledKey) ?? false;
} catch (e) {
return false;
}
}
/// Enable biometric login for a user
Future<bool> enableBiometric(String userId) async {
try {
// First verify biometric is available
final availability = await checkAvailability();
if (availability != BiometricAvailability.available) {
return false;
}
// Test biometric authentication
final authenticated = await authenticate(
reason: 'Enable biometric login for faster access',
localizedReason: 'Enable biometric login for faster access to your 1356 day challenge',
);
if (authenticated) {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_biometricEnabledKey, true);
await prefs.setString(_biometricUserIdKey, userId);
return true;
}
return false;
} catch (e) {
return false;
}
}
/// Disable biometric login
Future<bool> disableBiometric() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_biometricEnabledKey);
await prefs.remove(_biometricUserIdKey);
return true;
} catch (e) {
return false;
}
}
/// Get the user ID associated with biometric login
Future<String?> getBiometricUserId() async {
try {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_biometricUserIdKey);
} catch (e) {
return null;
}
}
/// Authenticate with biometrics
Future<bool> authenticate({
String reason = 'Authenticate to access your account',
String? localizedReason,
bool useErrorDialogs = true,
bool stickyAuth = false,
bool biometricOnly = true,
}) async {
try {
final authenticated = await _localAuth.authenticate(
localizedReason: localizedReason ?? reason,
options: AuthenticationOptions(
useErrorDialogs: useErrorDialogs,
stickyAuth: stickyAuth,
biometricOnly: biometricOnly,
),
);
return authenticated;
} on PlatformException catch (e) {
// Handle common biometric errors
if (e.code == 'LockedOut') {
// User tried too many times
return false;
} else if (e.code == 'NotAvailable') {
// Biometric not available
return false;
} else if (e.code == 'NotEnrolled') {
// No biometric enrolled
return false;
} else if (e.code == 'OtherOperatingSystem') {
// Not supported on this platform
return false;
} else if (e.code == 'UserFallback') {
// User chose to use password instead
return false;
}
return false;
} catch (e) {
return false;
}
}
/// Get the primary biometric type for display
Future<BiometricType?> getPrimaryBiometricType() async {
try {
final availableBiometrics = await getAvailableBiometrics();
if (availableBiometrics.contains(BiometricType.face)) {
return BiometricType.face;
} else if (availableBiometrics.contains(BiometricType.fingerprint)) {
return BiometricType.fingerprint;
} else if (availableBiometrics.contains(BiometricType.iris)) {
return BiometricType.iris;
}
return null;
} catch (e) {
return null;
}
}
/// Get user-friendly biometric status message
Future<String> getBiometricStatusMessage() async {
final availability = await checkAvailability();
final isEnabled = await isBiometricEnabled();
switch (availability) {
case BiometricAvailability.available:
if (isEnabled) {
final type = await getPrimaryBiometricType();
if (type != null) {
return '${getBiometricDisplayName(type)} is enabled for quick login';
}
return 'Biometric authentication is enabled for quick login';
} else {
final type = await getPrimaryBiometricType();
if (type != null) {
return '${getBiometricDisplayName(type)} is available but not enabled';
}
return 'Biometric authentication is available but not enabled';
}
case BiometricAvailability.notAvailable:
return 'Biometric authentication is not available on this device';
case BiometricAvailability.notEnrolled:
return 'No biometric credentials enrolled on this device';
case BiometricAvailability.lockedOut:
return 'Biometric authentication is temporarily locked. Try again later.';
case BiometricAvailability.permanentlyUnavailable:
return 'Biometric authentication is permanently unavailable';
}
}
/// Check if this is a mobile platform that supports biometrics
bool get isMobilePlatform {
return !kIsWeb && (Platform.isIOS || Platform.isAndroid);
}
/// Get platform-specific biometric name
String getPlatformBiometricName() {
if (Platform.isIOS) {
return 'Face ID / Touch ID';
} else if (Platform.isAndroid) {
return 'Fingerprint / Face Unlock';
}
return 'Biometric Authentication';
}
}
@@ -117,10 +117,23 @@ If user context is provided, use it to personalise your responses while respecti
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
final choices = data['choices'] as List;
final firstChoice = choices.first as Map<String, dynamic>;
final message = firstChoice['message'] as Map<String, dynamic>;
return message['content'] as String;
final choices = data['choices'] as List?;
if (choices == null || choices.isEmpty) {
throw MistralAIException('No choices returned in response');
}
final firstChoice = choices.first as Map<String, dynamic>?;
if (firstChoice == null) {
throw MistralAIException('Invalid choice format in response');
}
final message = firstChoice['message'] as Map<String, dynamic>?;
if (message == null) {
throw MistralAIException('No message in choice');
}
final content = message['content'] as String?;
if (content == null) {
throw MistralAIException('No content in message');
}
return content;
} else {
throw MistralAIException(
'Failed to get chat response',
@@ -6,11 +6,12 @@ class OfflineCacheService {
static const String _userBoxName = 'cached_user';
static const String _countdownBoxName = 'cached_countdown';
late Box<CachedGoal> _goalsBox;
late Box _userBox;
late Box _countdownBox;
Box<CachedGoal>? _goalsBox;
Box? _userBox;
Box? _countdownBox;
Future<void> init() async {
try {
await Hive.initFlutter();
if (!Hive.isAdapterRegistered(0)) {
@@ -20,74 +21,101 @@ class OfflineCacheService {
_goalsBox = await Hive.openBox<CachedGoal>(_goalsBoxName);
_userBox = await Hive.openBox(_userBoxName);
_countdownBox = await Hive.openBox(_countdownBoxName);
} catch (e) {
print('Error initializing offline cache: $e');
}
}
Future<void> cacheGoals(List<CachedGoal> goals) async {
await _goalsBox.clear();
if (_goalsBox == null) return;
await _goalsBox!.clear();
for (var goal in goals) {
await _goalsBox.put(goal.id, goal);
await _goalsBox!.put(goal.id, goal);
}
}
Future<List<CachedGoal>> getCachedGoals() async {
return _goalsBox.values.toList();
if (_goalsBox == null) return [];
return _goalsBox!.values.toList();
}
Future<CachedGoal?> getCachedGoal(String goalId) async {
return _goalsBox.get(goalId);
if (_goalsBox == null) return null;
return _goalsBox!.get(goalId);
}
Future<void> cacheGoal(CachedGoal goal) async {
await _goalsBox.put(goal.id, goal);
if (_goalsBox == null) return;
await _goalsBox!.put(goal.id, goal);
}
Future<void> deleteCachedGoal(String goalId) async {
await _goalsBox.delete(goalId);
if (_goalsBox == null) return;
await _goalsBox!.delete(goalId);
}
Future<void> markGoalAsDirty(String goalId) async {
final goal = _goalsBox.get(goalId);
if (_goalsBox == null) return;
final goal = _goalsBox!.get(goalId);
if (goal != null) {
await _goalsBox.put(goalId, goal.copyWith(isDirty: true));
await _goalsBox!.put(goalId, goal.copyWith(isDirty: true));
}
}
Future<List<CachedGoal>> getDirtyGoals() async {
return _goalsBox.values.where((goal) => goal.isDirty).toList();
if (_goalsBox == null) return [];
return _goalsBox!.values.where((goal) => goal.isDirty).toList();
}
Future<void> clearDirtyFlag(String goalId) async {
final goal = _goalsBox.get(goalId);
if (_goalsBox == null) return;
final goal = _goalsBox!.get(goalId);
if (goal != null) {
await _goalsBox.put(goalId, goal.copyWith(isDirty: false));
await _goalsBox!.put(goalId, goal.copyWith(isDirty: false));
}
}
Future<void> cacheUserData(Map<String, dynamic> userData) async {
await _userBox.putAll(userData);
if (_userBox == null) return;
await _userBox!.putAll(userData);
}
Future<Map<String, dynamic>> getCachedUserData() async {
return Map<String, dynamic>.from(_userBox.toMap());
if (_userBox == null) return {};
return Map<String, dynamic>.from(_userBox!.toMap());
}
Future<void> cacheCountdownData(Map<String, dynamic> countdownData) async {
await _countdownBox.putAll(countdownData);
if (_countdownBox == null) return;
await _countdownBox!.putAll(countdownData);
}
Future<Map<String, dynamic>> getCachedCountdownData() async {
return Map<String, dynamic>.from(_countdownBox.toMap());
if (_countdownBox == null) return {};
return Map<String, dynamic>.from(_countdownBox!.toMap());
}
Future<void> clearAllCache() async {
await _goalsBox.clear();
await _userBox.clear();
await _countdownBox.clear();
if (_goalsBox != null) await _goalsBox!.clear();
if (_userBox != null) await _userBox!.clear();
if (_countdownBox != null) await _countdownBox!.clear();
}
Future<void> close() async {
await _goalsBox.close();
await _userBox.close();
await _countdownBox.close();
if (_goalsBox != null) await _goalsBox!.close();
if (_userBox != null) await _userBox!.close();
if (_countdownBox != null) await _countdownBox!.close();
}
}
@@ -49,7 +49,7 @@ class AchievementsState {
}
class AchievementsController extends StateNotifier<AchievementsState> {
final AchievementsRepository _repository;
final AchievementsRepository? _repository;
final AuthController _authController;
AchievementsController(
@@ -60,14 +60,16 @@ class AchievementsController extends StateNotifier<AchievementsState> {
}
Future<void> _loadAchievements() async {
if (_repository == null) return;
final userId = _authController.currentUserId;
if (userId == null) return;
state = state.copyWith(isLoading: true);
try {
final available = await _repository.getAvailableAchievements();
final unlocked = await _repository.getUserAchievements(userId);
final available = await _repository!.getAvailableAchievements();
final unlocked = await _repository!.getUserAchievements(userId);
state = state.copyWith(
isLoading: false,
@@ -86,11 +88,13 @@ class AchievementsController extends StateNotifier<AchievementsState> {
AchievementType type,
int currentValue,
) async {
if (_repository == null) return null;
final userId = _authController.currentUserId;
if (userId == null) return null;
try {
final newlyUnlocked = await _repository.checkAndUnlockAchievement(
final newlyUnlocked = await _repository!.checkAndUnlockAchievement(
userId,
type,
currentValue,
@@ -135,6 +139,9 @@ final achievementsControllerProvider =
);
});
final achievementsRepositoryProvider = Provider<AchievementsRepository>((ref) {
return AchievementsRepository(supabaseClient);
final achievementsRepositoryProvider = Provider<AchievementsRepository?>((ref) {
final client = supabaseClient;
if (client == null) return null;
return AchievementsRepository(client);
});
@@ -27,7 +27,7 @@ class AIChatState {
this.isRecording = false,
this.error,
this.currentTranscription,
this.privacyModeEnabled = true,
this.privacyModeEnabled = false,
});
AIChatState copyWith({
@@ -154,6 +154,13 @@ class AIChatController extends StateNotifier<AIChatState> {
'User privacy mode is DISABLED. Use the following personal context to personalise your coaching:');
buffer.writeln('Username: ${user.username}.');
if (user.heightCm != null) {
buffer.writeln('Height: ${user.heightCm!.toStringAsFixed(1)} cm.');
}
if (user.weightKg != null) {
buffer.writeln('Weight: ${user.weightKg!.toStringAsFixed(1)} kg.');
}
if (countdownSummary != null) {
buffer.writeln(countdownSummary);
} else {
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import '../application/ai_chat_controller.dart';
class AIChatScreen extends ConsumerStatefulWidget {
@@ -64,7 +65,7 @@ class _AIChatScreenState extends ConsumerState<AIChatScreen> {
Expanded(
child: state.messages.isEmpty
? _buildEmptyState(context)
: _buildMessagesList(state.messages),
: _buildMessagesList(state.messages, state.isLoading),
),
// Error message
@@ -174,12 +175,16 @@ class _AIChatScreenState extends ConsumerState<AIChatScreen> {
);
}
Widget _buildMessagesList(List messages) {
Widget _buildMessagesList(List messages, bool isLoading) {
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(16),
itemCount: messages.length,
itemCount: messages.length + (isLoading ? 1 : 0),
itemBuilder: (context, index) {
if (index == messages.length && isLoading) {
return _buildLoadingIndicator();
}
final message = messages[index];
final isUser = message.role == 'user';
@@ -218,12 +223,27 @@ class _AIChatScreenState extends ConsumerState<AIChatScreen> {
: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Text(
child: isUser
? Text(
message.content,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: isUser
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onSurfaceVariant,
color: Theme.of(context).colorScheme.onPrimary,
),
)
: MarkdownBody(
data: message.content,
styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context)).copyWith(
p: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
code: Theme.of(context).textTheme.bodySmall?.copyWith(
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
codeblockDecoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(8),
),
),
),
),
@@ -245,6 +265,59 @@ class _AIChatScreenState extends ConsumerState<AIChatScreen> {
);
}
Widget _buildLoadingIndicator() {
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
CircleAvatar(
radius: 16,
backgroundColor: Theme.of(context).colorScheme.primary,
child: Icon(
Icons.psychology,
size: 20,
color: Theme.of(context).colorScheme.onPrimary,
),
),
const SizedBox(width: 8),
Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.75,
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(width: 8),
Text(
'AI is thinking...',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
);
}
Widget _buildErrorMessage(String error, controller) {
return Container(
margin: const EdgeInsets.all(16),
@@ -271,9 +271,9 @@ final insightsControllerProvider =
});
final goalsRepositoryProvider = Provider<GoalsRepository>((ref) {
return GoalsRepository(supabaseClient);
return GoalsRepository(supabaseClient ?? (throw Exception("Supabase not initialized")));
});
final countdownRepositoryProvider = Provider<CountdownRepository>((ref) {
return CountdownRepository(supabaseClient);
return CountdownRepository(supabaseClient ?? (throw Exception("Supabase not initialized")));
});
@@ -1,18 +1,23 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../data/repositories/auth_repository.dart';
import '../../../data/models/user_model.dart';
import '../../../data/services/biometric_service.dart';
import '../../../core/services/analytics_service.dart';
import '../../../bootstrap/supabase_client.dart';
import '../../../core/utils/unit_conversion_utils.dart';
import 'package:local_auth/local_auth.dart' as local_auth;
final authControllerProvider = StateNotifierProvider<AuthController, User?>((ref) {
return AuthController(ref.read(authRepositoryProvider));
});
final authRepositoryProvider = Provider<AuthRepository>((ref) {
return AuthRepository();
return AuthRepository(supabaseClient);
});
class AuthController extends StateNotifier<User?> {
final AuthRepository _authRepository;
final BiometricService _biometricService = BiometricService();
final AnalyticsService _analytics = AnalyticsService();
AuthController(this._authRepository) : super(null) {
@@ -46,8 +51,8 @@ class AuthController extends StateNotifier<User?> {
_analytics.logSignIn(method: 'email');
}
Future<void> signUpWithEmail(String email, String password, String username) async {
await _authRepository.signUpWithEmail(email, password, username);
Future<void> signUpWithEmail(String email, String password, String username, {double? heightCm, double? weightKg, int? age, Gender? gender, HeightUnit? heightUnit, WeightUnit? weightUnit}) async {
await _authRepository.signUpWithEmail(email, password, username, heightCm: heightCm, weightKg: weightKg, age: age, gender: gender, heightUnit: heightUnit, weightUnit: weightUnit);
_analytics.logSignUp(method: 'email');
}
@@ -77,6 +82,12 @@ class AuthController extends StateNotifier<User?> {
String? bio,
String? avatarUrl,
bool? isPublicProfile,
double? heightCm,
double? weightKg,
int? age,
Gender? gender,
HeightUnit? heightUnit,
WeightUnit? weightUnit,
}) async {
final updatedFields = <String>[];
if (username != null) updatedFields.add('username');
@@ -86,12 +97,24 @@ class AuthController extends StateNotifier<User?> {
updatedFields.add('visibility');
_analytics.logProfileVisibilityChanged(isPublic: isPublicProfile);
}
if (heightCm != null) updatedFields.add('height');
if (weightKg != null) updatedFields.add('weight');
if (age != null) updatedFields.add('age');
if (gender != null) updatedFields.add('gender');
if (heightUnit != null) updatedFields.add('height_unit');
if (weightUnit != null) updatedFields.add('weight_unit');
await _authRepository.updateProfile(
username: username,
bio: bio,
avatarUrl: avatarUrl,
isPublicProfile: isPublicProfile,
heightCm: heightCm,
weightKg: weightKg,
age: age,
gender: gender,
heightUnit: heightUnit,
weightUnit: weightUnit,
);
if (updatedFields.isNotEmpty) {
@@ -99,6 +122,98 @@ class AuthController extends StateNotifier<User?> {
}
}
// Biometric Authentication Methods
/// Check if biometric authentication is available
Future<BiometricAvailability> checkBiometricAvailability() async {
return await _biometricService.checkAvailability();
}
/// Check if biometric login is enabled
Future<bool> isBiometricEnabled() async {
return await _biometricService.isBiometricEnabled();
}
/// Enable biometric login for current user
Future<bool> enableBiometric() async {
final userId = currentUserId;
if (userId == null) return false;
final success = await _biometricService.enableBiometric(userId);
if (success) {
_analytics.logEvent('biometric_enabled');
}
return success;
}
/// Disable biometric login
Future<bool> disableBiometric() async {
final success = await _biometricService.disableBiometric();
if (success) {
_analytics.logEvent('biometric_disabled');
}
return success;
}
/// Authenticate with biometrics and sign in
Future<bool> signInWithBiometric() async {
try {
// Check if biometric is enabled
final isEnabled = await _biometricService.isBiometricEnabled();
if (!isEnabled) {
return false;
}
// Get the stored user ID
final biometricUserId = await _biometricService.getBiometricUserId();
if (biometricUserId == null) {
return false;
}
// Authenticate with biometrics
final authenticated = await _biometricService.authenticate(
reason: 'Sign in to your 1356 day challenge',
localizedReason: 'Use your biometric to quickly access your challenge',
);
if (authenticated) {
// Try to restore session for the stored user
await _authRepository.refreshSession();
// Verify the current user matches the stored biometric user
final currentUser = _authRepository.currentUser;
final currentUserId = currentUser?.id;
if (currentUserId == biometricUserId) {
_analytics.logSignIn(method: 'biometric');
return true;
} else {
// User mismatch, disable biometric
await disableBiometric();
return false;
}
}
return false;
} catch (e) {
return false;
}
}
/// Get biometric status message
Future<String> getBiometricStatusMessage() async {
return await _biometricService.getBiometricStatusMessage();
}
/// Get available biometric types
Future<List<local_auth.BiometricType>> getAvailableBiometrics() async {
return await _biometricService.getAvailableBiometrics();
}
/// Get primary biometric type
Future<local_auth.BiometricType?> getPrimaryBiometricType() async {
return await _biometricService.getPrimaryBiometricType();
}
@override
void dispose() {
_authRepository.dispose();
@@ -102,7 +102,7 @@ class _AuthChoiceScreenState extends ConsumerState<AuthChoiceScreen> {
),
const SizedBox(height: 24),
Text(
'LifeTimer',
'1356',
style: Theme.of(context)
.textTheme
.headlineLarge
@@ -2,18 +2,38 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../application/auth_controller.dart';
import '../../onboarding/application/onboarding_controller.dart';
import '../../profile/application/profile_controller.dart';
import 'auth_showcase_screen.dart';
import '../../onboarding/presentation/onboarding_intro_screen.dart';
import '../../profile/presentation/profile_setup_screen.dart';
import '../../countdown/presentation/home_countdown_screen.dart';
import '../../../bootstrap/supabase_client.dart';
class AuthGate extends ConsumerWidget {
class AuthGate extends ConsumerStatefulWidget {
const AuthGate({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<AuthGate> createState() => _AuthGateState();
}
class _AuthGateState extends ConsumerState<AuthGate> {
bool _isCheckingProfile = false;
bool _profileSetupComplete = false;
@override
Widget build(BuildContext context) {
final authState = ref.watch(authControllerProvider);
final onboardingState = ref.watch(onboardingControllerProvider);
// If no backend is configured and there is no overridden auth state,
// keep the app usable by continuing through the local onboarding flow.
if (supabaseClient == null && authState == null) {
if (!onboardingState) {
return const OnboardingIntroScreen();
}
return const HomeCountdownScreen();
}
if (authState == null) {
return const AuthShowcaseScreen();
}
@@ -23,7 +43,29 @@ class AuthGate extends ConsumerWidget {
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();
}
Future<void> _checkProfileSetup(String userId) async {
final isComplete = await ref.read(profileControllerProvider.notifier).isProfileSetupComplete(userId);
if (mounted) {
setState(() {
_profileSetupComplete = isComplete;
_isCheckingProfile = false;
});
}
}
}
@@ -64,7 +64,7 @@ class AuthShowcaseScreen extends ConsumerWidget {
),
const SizedBox(height: 16),
Text(
'LifeTimer helps you design a 1356-day experiment, focus on a small set of meaningful goals, and see time as a single bold countdown.',
'1356 helps you design a 1356-day experiment, focus on a small set of meaningful goals, and see time as a single bold countdown.',
style: theme.textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withValues(alpha:0.7),
height: 1.6,
@@ -3,10 +3,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../application/auth_controller.dart';
import '../../../data/services/biometric_service.dart';
import '../../../core/widgets/app_scaffold.dart';
import '../../../core/widgets/primary_button.dart';
import '../../../core/utils/validators.dart';
import '../application/auth_controller.dart';
class SignInScreen extends ConsumerStatefulWidget {
const SignInScreen({super.key});
@@ -19,8 +21,86 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final BiometricService _biometricService = BiometricService();
bool _isLoading = false;
bool _obscurePassword = true;
bool _isBiometricAvailable = false;
bool _isBiometricEnabled = false;
bool _isBiometricLoading = false;
bool _showEmailVerificationMessage = false;
@override
void initState() {
super.initState();
_checkBiometricStatus();
_checkIfComingFromRegistration();
}
void _checkIfComingFromRegistration() async {
// Check if user navigated from registration by checking shared preferences
final prefs = await SharedPreferences.getInstance();
final justRegistered = prefs.getBool('just_registered') ?? false;
final registrationTime = prefs.getInt('registration_time') ?? 0;
if (mounted) {
// Show message if registration happened in the last 5 minutes
final now = DateTime.now().millisecondsSinceEpoch;
final fiveMinutesAgo = now - (5 * 60 * 1000);
if (justRegistered && registrationTime > fiveMinutesAgo) {
setState(() {
_showEmailVerificationMessage = true;
});
// Clear the flag so it doesn't show again
await prefs.remove('just_registered');
await prefs.remove('registration_time');
}
}
}
Future<void> _checkBiometricStatus() async {
try {
final availability = await _biometricService.checkAvailability();
final isEnabled = await _biometricService.isBiometricEnabled();
if (mounted) {
setState(() {
_isBiometricAvailable = availability == BiometricAvailability.available;
_isBiometricEnabled = isEnabled;
});
}
} catch (e) {
// Biometric not available, ignore
}
}
Future<void> _handleBiometricSignIn() async {
setState(() => _isBiometricLoading = true);
try {
final authController = ref.read(authControllerProvider.notifier);
final success = await authController.signInWithBiometric();
if (success) {
// Navigation will be handled by AuthGate
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Biometric login failed')),
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Biometric login error: ${e.toString()}')),
);
}
} finally {
if (mounted) {
setState(() => _isBiometricLoading = false);
}
}
}
Future<void> _handleSignIn() async {
if (!_formKey.currentState!.validate()) return;
@@ -111,7 +191,7 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'LifeTimer',
'1356',
style: Theme.of(context)
.textTheme
.titleMedium
@@ -172,6 +252,45 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
.withOpacity(0.7),
),
),
const SizedBox(height: 16),
// Email verification reminder message
if (_showEmailVerificationMessage)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.withOpacity(0.3)),
),
child: Row(
children: [
Icon(Icons.info_outline, color: Colors.blue.shade700, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
'Please verify your email before signing in. Check your inbox for the verification link.',
style: TextStyle(
color: Colors.blue.shade700,
fontSize: 12,
),
),
),
IconButton(
icon: const Icon(Icons.close, size: 16),
onPressed: () {
setState(() {
_showEmailVerificationMessage = false;
});
},
color: Colors.blue.shade700,
),
],
),
),
if (_showEmailVerificationMessage)
const SizedBox(height: 16),
const SizedBox(height: 24),
Semantics(
label: 'Email address field',
@@ -240,6 +359,39 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
),
),
const SizedBox(height: 24),
// Biometric Login Button
if (_isBiometricAvailable && _isBiometricEnabled)
Container(
width: double.infinity,
height: 48,
child: ElevatedButton.icon(
onPressed: _isBiometricLoading ? null : _handleBiometricSignIn,
icon: _isBiometricLoading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.fingerprint),
label: Text(_isBiometricLoading ? 'Authenticating...' : 'Sign in with Biometric'),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.surface,
foregroundColor: Theme.of(context).colorScheme.onSurface,
elevation: 0,
side: BorderSide(
color: Theme.of(context).colorScheme.outline,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
if (_isBiometricAvailable && _isBiometricEnabled)
const SizedBox(height: 12),
PrimaryButton(
onPressed: _handleSignIn,
text: _isLoading ? 'Signing in...' : 'Sign In',
@@ -3,10 +3,13 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../core/widgets/app_scaffold.dart';
import '../../../core/widgets/primary_button.dart';
import '../../../core/widgets/unit_input_field.dart';
import '../../../core/utils/validators.dart';
import '../application/auth_controller.dart';
import '../../../core/utils/unit_conversion_utils.dart';
class SignUpScreen extends ConsumerStatefulWidget {
const SignUpScreen({super.key});
@@ -21,20 +24,57 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
final _usernameController = TextEditingController();
final _ageController = TextEditingController();
Gender? _selectedGender;
bool _isLoading = false;
bool _obscurePassword = true;
bool _obscureConfirmPassword = true;
bool _showAdditionalInfo = false;
double? _heightCm;
double? _weightKg;
HeightUnit _selectedHeightUnit = HeightUnit.metric;
WeightUnit _selectedWeightUnit = WeightUnit.metric;
Future<void> _handleSignUp() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
final age = _ageController.text.trim().isNotEmpty
? int.tryParse(_ageController.text.trim())
: null;
await ref.read(authControllerProvider.notifier).signUpWithEmail(
_emailController.text.trim(),
_passwordController.text,
_usernameController.text.trim(),
heightCm: _heightCm,
weightKg: _weightKg,
age: age,
gender: _selectedGender,
heightUnit: _selectedHeightUnit,
weightUnit: _selectedWeightUnit,
);
// Show success message and navigate to login screen
if (mounted) {
// Mark that registration just happened for the login screen
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('just_registered', true);
await prefs.setInt('registration_time', DateTime.now().millisecondsSinceEpoch);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Account created successfully! Please check your email and verify it before signing in.'),
backgroundColor: Colors.green,
duration: Duration(seconds: 4),
),
);
// Navigate to login screen after successful registration
context.pushReplacement('/sign-in');
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -54,6 +94,7 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
_passwordController.dispose();
_confirmPasswordController.dispose();
_usernameController.dispose();
_ageController.dispose();
super.dispose();
}
@@ -91,7 +132,7 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'LifeTimer',
'1356',
style: Theme.of(context)
.textTheme
.titleMedium
@@ -235,6 +276,121 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
onFieldSubmitted: (_) => _handleSignUp(),
),
const SizedBox(height: 24),
// Additional optional info section
GestureDetector(
onTap: () {
setState(() {
_showAdditionalInfo = !_showAdditionalInfo;
});
},
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
),
),
child: Row(
children: [
Icon(
_showAdditionalInfo
? Icons.expand_less
: Icons.expand_more,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Add more info for better recommendations',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
fontWeight: FontWeight.w500,
),
),
),
],
),
),
),
if (_showAdditionalInfo) ...[
const SizedBox(height: 16),
UnitInputField(
labelText: 'Height',
prefixIcon: Icons.height_outlined,
helperText: 'Optional: For personalized recommendations',
enabled: !_isLoading,
onValueChanged: (value) {
setState(() {
_heightCm = value;
});
},
onUnitChanged: (unit) {
setState(() {
_selectedHeightUnit = unit as HeightUnit;
});
},
isHeight: true,
),
const SizedBox(height: 16),
UnitInputField(
labelText: 'Weight',
prefixIcon: Icons.monitor_weight_outlined,
helperText: 'Optional: For personalized recommendations',
enabled: !_isLoading,
onValueChanged: (value) {
setState(() {
_weightKg = value;
});
},
onUnitChanged: (unit) {
setState(() {
_selectedWeightUnit = unit as WeightUnit;
});
},
isHeight: false,
),
const SizedBox(height: 16),
TextFormField(
controller: _ageController,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
labelText: 'Age',
prefixIcon: Icon(Icons.cake_outlined),
helperText: 'Optional: For age-appropriate recommendations',
),
enabled: !_isLoading,
),
const SizedBox(height: 16),
DropdownButtonFormField<Gender>(
value: _selectedGender,
decoration: const InputDecoration(
labelText: 'Gender',
prefixIcon: Icon(Icons.person_outline),
helperText: 'Optional: For personalized recommendations',
),
items: const [
DropdownMenuItem(
value: Gender.male,
child: Text('Male'),
),
DropdownMenuItem(
value: Gender.female,
child: Text('Female'),
),
],
onChanged: _isLoading ? null : (Gender? value) {
setState(() {
_selectedGender = value;
});
},
),
],
const SizedBox(height: 24),
PrimaryButton(
onPressed: _handleSignUp,
text: _isLoading
@@ -93,7 +93,7 @@ class CalendarController extends StateNotifier<CalendarState> {
}
final calendarRepositoryProvider = Provider<CalendarRepository>((ref) {
return CalendarRepository(supabaseClient);
return CalendarRepository(supabaseClient ?? (throw Exception("Supabase not initialized")));
});
final calendarControllerProvider =
@@ -522,6 +522,7 @@ Future<void> _showAddCalendarEntrySheet(
],
ElevatedButton(
onPressed: () async {
final navigator = Navigator.of(sheetContext);
await ref
.read(calendarControllerProvider.notifier)
.addEntry(
@@ -529,8 +530,8 @@ Future<void> _showAddCalendarEntrySheet(
note: noteController.text,
goalId: selectedGoalId,
);
if (Navigator.of(sheetContext).canPop()) {
Navigator.of(sheetContext).pop();
if (navigator.canPop()) {
navigator.pop();
}
},
child: const Text('Save to calendar'),
@@ -149,7 +149,7 @@ class CountdownLoaded extends CountdownState {
}
final countdownRepositoryProvider = Provider<CountdownRepository>((ref) {
return CountdownRepository(supabaseClient);
return CountdownRepository(supabaseClient ?? (throw Exception("Supabase not initialized")));
});
final countdownControllerProvider = StateNotifierProvider<CountdownController, CountdownState>((ref) {
@@ -28,9 +28,7 @@ class _HomeCountdownScreenState extends ConsumerState<HomeCountdownScreen> {
? achievementsState.level
: null;
return AppScaffold(
body: SafeArea(
child: countdownState.isLoading
final child = countdownState.isLoading
? const Center(child: LoadingIndicator())
: countdownState.error != null
? Center(
@@ -51,6 +49,13 @@ class _HomeCountdownScreenState extends ConsumerState<HomeCountdownScreen> {
: _CountdownActiveScreen(
user: countdownState.user!,
level: level,
);
return AppScaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.only(top: 30.0, bottom: 30.0),
child: child,
),
),
floatingActionButton: FloatingActionButton(
@@ -156,7 +161,7 @@ class _CountdownActiveScreen extends StatelessWidget {
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: Padding(
padding: const EdgeInsets.all(24.0),
padding: const EdgeInsets.only(top: 30.0, left: 24.0, right: 24.0, bottom: 30.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
@@ -154,7 +154,11 @@ class GoalsState {
}
final goalsRepositoryProvider = Provider<GoalsRepository>((ref) {
return GoalsRepository(supabaseClient);
final client = supabaseClient;
if (client == null) {
throw Exception('Supabase not initialized - goals repository unavailable');
}
return GoalsRepository(client);
});
final goalsControllerProvider = StateNotifierProvider<GoalsController, GoalsState>((ref) {
@@ -7,10 +7,10 @@ class OnboardingController extends StateNotifier<bool> {
static const String _onboardingKey = 'onboarding_completed';
OnboardingController() : super(false) {
_loadOnboardingStatus();
loadOnboardingStatus();
}
Future<void> _loadOnboardingStatus() async {
Future<void> loadOnboardingStatus() async {
try {
final box = await Hive.openBox('app_settings');
final completed = box.get(_onboardingKey, defaultValue: false);
@@ -16,40 +16,14 @@ class OnboardingHowItWorksScreen extends ConsumerWidget {
return AppScaffold(
body: SafeArea(
child: Column(
children: [
// Progress indicator and back button
Padding(
padding: const EdgeInsets.all(24.0),
child: Row(
children: [
IconButton(
onPressed: () {
context.pop();
},
icon: const Icon(Icons.arrow_back),
),
Expanded(
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
],
),
),
// Scrollable content
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: Padding(
padding: const EdgeInsets.only(top: 30.0, left: 24.0, right: 24.0, bottom: 30.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 8),
// Progress Bar and Navigation
const _OnboardingProgress(currentStep: 2, totalSteps: 3),
const SizedBox(height: 24),
Text(
'How It Works',
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
@@ -57,42 +31,53 @@ class OnboardingHowItWorksScreen extends ConsumerWidget {
),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
const SizedBox(height: 32),
const _StepCard(
number: 1,
title: 'Create Your Bucket List',
description: 'Add between 1 and 20 goals you want to achieve. Each goal can have a description, location, and image.',
icon: Icons.edit_note,
),
const SizedBox(height: 12),
const SizedBox(height: 16),
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 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: 24),
PrimaryButton(
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextButton(
onPressed: () => context.pop(),
child: const Text('Back'),
),
),
const SizedBox(width: 16),
Expanded(
flex: 2,
child: PrimaryButton(
onPressed: () {
controller.completeStep('how_it_works');
context.push('/onboarding/motivation');
},
text: 'Continue',
),
const SizedBox(height: 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,55 +16,29 @@ class OnboardingIntroScreen extends ConsumerWidget {
return AppScaffold(
body: SafeArea(
child: Column(
children: [
// Progress indicator and back button
Padding(
padding: const EdgeInsets.all(24.0),
child: Row(
children: [
IconButton(
onPressed: () {
// Can't go back from intro, go to auth choice
context.push('/auth-choice');
},
icon: const Icon(Icons.arrow_back),
),
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
],
),
),
// Scrollable content
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: Padding(
padding: const EdgeInsets.only(top: 20.0, left: 24.0, right: 24.0, bottom: 20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 16),
// Progress Bar and Navigation
const _OnboardingProgress(currentStep: 1, totalSteps: 3),
const SizedBox(height: 48),
const Icon(
Icons.timer_outlined,
size: 64,
size: 100,
color: null,
),
const SizedBox(height: 20),
const SizedBox(height: 32),
Text(
'Welcome to LifeTimer',
'Welcome to 1356',
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 10),
const SizedBox(height: 16),
Text(
'Your 1356-day journey starts here.\nCreate your bucket list and begin your countdown.',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
@@ -73,49 +47,56 @@ class OnboardingIntroScreen extends ConsumerWidget {
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
const SizedBox(height: 48),
const _FeatureCard(
icon: Icons.flag,
title: 'Set Your Goals',
description: 'Create a bucket list of 1-20 meaningful goals',
),
const SizedBox(height: 10),
const SizedBox(height: 16),
const _FeatureCard(
icon: Icons.lock_clock,
title: 'Fixed Timeline',
description: '1356 days to achieve everything - no extensions',
),
const SizedBox(height: 10),
const SizedBox(height: 16),
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(
const SizedBox(height: 48),
Row(
children: [
Expanded(
child: TextButton(
onPressed: () async {
await controller.skipOnboarding();
if (context.mounted) {
context.push('/home');
}
},
child: const Text('Skip onboarding'),
child: const Text('Skip'),
),
const SizedBox(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,46 +16,21 @@ class OnboardingMotivationScreen extends ConsumerWidget {
return AppScaffold(
body: SafeArea(
child: Column(
children: [
// Progress indicator and back button
Padding(
padding: const EdgeInsets.all(24.0),
child: Row(
children: [
IconButton(
onPressed: () {
context.pop();
},
icon: const Icon(Icons.arrow_back),
),
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: Padding(
padding: const EdgeInsets.only(top: 30.0, left: 24.0, right: 24.0, bottom: 30.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 8),
// Progress Bar and Navigation
const _OnboardingProgress(currentStep: 3, totalSteps: 3),
const SizedBox(height: 24),
const Icon(
Icons.psychology_outlined,
size: 64,
size: 80,
color: Colors.amber,
),
const SizedBox(height: 16),
const SizedBox(height: 24),
Text(
'Your Time is Now',
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
@@ -63,7 +38,7 @@ class OnboardingMotivationScreen extends ConsumerWidget {
),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
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, '
@@ -75,19 +50,19 @@ class OnboardingMotivationScreen extends ConsumerWidget {
),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
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: 10),
const SizedBox(height: 16),
const _MotivationCard(
icon: Icons.people,
title: 'Join Community',
description: 'Connect with others on similar journeys (optional).',
),
const SizedBox(height: 10),
const SizedBox(height: 16),
const _MotivationCard(
icon: Icons.celebration,
title: 'Celebrate Wins',
@@ -99,18 +74,16 @@ class OnboardingMotivationScreen extends ConsumerWidget {
controller.completeStep('motivation');
await controller.completeOnboarding();
if (context.mounted) {
context.push('/profile-setup');
context.push('/profile/create');
}
},
text: 'Get Started',
),
const SizedBox(height: 20),
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:supabase_flutter/supabase_flutter.dart' as supabase;
import '../../../data/models/user_model.dart' as app;
import '../../../data/repositories/user_repository.dart';
import '../../../bootstrap/supabase_client.dart';
import '../../../core/errors/failure.dart';
import '../../../core/utils/unit_conversion_utils.dart';
final profileControllerProvider = StateNotifierProvider<ProfileController, ProfileState>((ref) {
final client = supabase.Supabase.instance.client;
final repository = UserRepository(client);
final client = supabaseClient;
final repository = client != null ? UserRepository(client) : null;
return ProfileController(repository);
});
class ProfileController extends StateNotifier<ProfileState> {
final UserRepository _repository;
final UserRepository? _repository;
ProfileController(this._repository) : super(const ProfileState.initial());
Future<void> loadProfile(String userId) async {
if (_repository == null) {
state = const ProfileState.error('Profile is unavailable while offline.');
return;
}
state = const ProfileState.loading();
try {
final user = await _repository.getProfile(userId);
final user = await _repository!.getProfile(userId);
state = ProfileState.loaded(user);
} on Failure catch (failure) {
state = ProfileState.error(failure.message);
@@ -28,15 +34,20 @@ class ProfileController extends StateNotifier<ProfileState> {
}
Future<void> updateUsername(String userId, String username) async {
if (_repository == null) {
state = const ProfileState.error('Profile is unavailable while offline.');
return;
}
state = const ProfileState.loading();
try {
final isAvailable = await _repository.isUsernameAvailable(username);
final isAvailable = await _repository!.isUsernameAvailable(username);
if (!isAvailable) {
state = const ProfileState.error('Username is already taken');
return;
}
final updatedUser = await _repository.updateProfile(
final updatedUser = await _repository!.updateProfile(
userId: userId,
username: username,
);
@@ -49,9 +60,14 @@ class ProfileController extends StateNotifier<ProfileState> {
}
Future<void> updateBio(String userId, String bio) async {
if (_repository == null) {
state = const ProfileState.error('Profile is unavailable while offline.');
return;
}
state = const ProfileState.loading();
try {
final updatedUser = await _repository.updateProfile(
final updatedUser = await _repository!.updateProfile(
userId: userId,
bio: bio,
);
@@ -64,9 +80,14 @@ class ProfileController extends StateNotifier<ProfileState> {
}
Future<void> updateAvatarUrl(String userId, String avatarUrl) async {
if (_repository == null) {
state = const ProfileState.error('Profile is unavailable while offline.');
return;
}
state = const ProfileState.loading();
try {
final updatedUser = await _repository.updateProfile(
final updatedUser = await _repository!.updateProfile(
userId: userId,
avatarUrl: avatarUrl,
);
@@ -79,12 +100,17 @@ class ProfileController extends StateNotifier<ProfileState> {
}
Future<void> toggleProfileVisibility(String userId) async {
if (_repository == null) {
state = const ProfileState.error('Profile is unavailable while offline.');
return;
}
final currentState = state;
if (currentState.user == null) return;
state = const ProfileState.loading();
try {
final updatedUser = await _repository.updateProfile(
final updatedUser = await _repository!.updateProfile(
userId: userId,
isPublicProfile: !currentState.user!.isPublicProfile,
);
@@ -105,16 +131,27 @@ class ProfileController extends StateNotifier<ProfileState> {
String? instagramHandle,
String? tiktokHandle,
String? websiteUrl,
Gender? gender,
DateTime? birthDate,
double? heightCm,
double? weightKg,
HeightUnit heightUnit = HeightUnit.metric,
WeightUnit weightUnit = WeightUnit.metric,
}) async {
if (_repository == null) {
state = const ProfileState.error('Profile is unavailable while offline.');
return;
}
state = const ProfileState.loading();
try {
final isAvailable = await _repository.isUsernameAvailable(username);
final isAvailable = await _repository!.isUsernameAvailable(username);
if (!isAvailable) {
state = const ProfileState.error('Username is already taken');
return;
}
final updatedUser = await _repository.updateProfile(
final updatedUser = await _repository!.updateProfile(
userId: userId,
username: username,
bio: bio,
@@ -123,6 +160,12 @@ class ProfileController extends StateNotifier<ProfileState> {
instagramHandle: instagramHandle,
tiktokHandle: tiktokHandle,
websiteUrl: websiteUrl,
gender: gender,
birthDate: birthDate,
heightCm: heightCm,
weightKg: weightKg,
heightUnit: heightUnit,
weightUnit: weightUnit,
);
state = ProfileState.loaded(updatedUser);
} on Failure catch (failure) {
@@ -131,6 +174,19 @@ class ProfileController extends StateNotifier<ProfileState> {
state = ProfileState.error(e.toString());
}
}
Future<bool> isProfileSetupComplete(String userId) async {
if (_repository == null) {
return false;
}
try {
final user = await _repository!.getProfile(userId);
return user.username.isNotEmpty;
} catch (e) {
return false;
}
}
}
class ProfileState {
@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
import '../../../bootstrap/supabase_client.dart';
import '../../../core/widgets/app_scaffold.dart';
import '../../../core/widgets/loading_indicator.dart';
import '../../../core/widgets/empty_state.dart';
@@ -23,7 +23,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
@override
void initState() {
super.initState();
final userId = supabase.Supabase.instance.client.auth.currentUser?.id;
final userId = currentSupabaseUserId;
if (userId != null) {
ref.read(profileControllerProvider.notifier).loadProfile(userId);
}
@@ -33,10 +33,11 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
Widget build(BuildContext context) {
final profileState = ref.watch(profileControllerProvider);
final achievementsState = ref.watch(achievementsControllerProvider);
final userId = supabase.Supabase.instance.client.auth.currentUser?.id;
final userId = currentSupabaseUserId;
if (userId == null) {
return AppScaffold(
title: 'Profile',
body: Semantics(
label: 'Not signed in',
child: const EmptyState(
@@ -105,6 +106,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
SliverAppBar(
expandedHeight: 200,
pinned: true,
title: const Text('Profile'),
flexibleSpace: FlexibleSpaceBar(
background: Container(
decoration: BoxDecoration(
@@ -408,7 +410,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
);
if (confirmed == true && context.mounted) {
await supabase.Supabase.instance.client.auth.signOut();
await signOutCurrentSupabaseUser();
if (context.mounted) {
context.go('/');
}
@@ -8,6 +8,7 @@ import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
import '../../../core/widgets/app_scaffold.dart';
import '../../../core/widgets/primary_button.dart';
import '../../../core/utils/validators.dart';
import '../../../core/utils/unit_conversion_utils.dart';
import '../application/profile_controller.dart';
class ProfileSetupScreen extends ConsumerStatefulWidget {
@@ -25,6 +26,8 @@ class _ProfileSetupScreenState extends ConsumerState<ProfileSetupScreen> {
final _instagramController = TextEditingController();
final _tiktokController = TextEditingController();
final _websiteController = TextEditingController();
final _heightController = TextEditingController();
final _weightController = TextEditingController();
dynamic _avatarFile;
String? _avatarUrl;
@@ -33,6 +36,11 @@ class _ProfileSetupScreenState extends ConsumerState<ProfileSetupScreen> {
bool _isUsernameAvailable = true;
String? _usernameError;
Gender? _selectedGender;
DateTime? _selectedBirthDate;
HeightUnit _selectedHeightUnit = HeightUnit.metric;
WeightUnit _selectedWeightUnit = WeightUnit.metric;
final ImagePicker _imagePicker = ImagePicker();
Future<void> _pickAvatar() async {
@@ -152,6 +160,18 @@ class _ProfileSetupScreenState extends ConsumerState<ProfileSetupScreen> {
uploadedAvatarUrl = await _uploadAvatar();
}
// Parse height and weight values
double? heightCm;
double? weightKg;
if (_heightController.text.isNotEmpty) {
heightCm = UnitConversionUtils.parseHeight(_heightController.text, _selectedHeightUnit);
}
if (_weightController.text.isNotEmpty) {
weightKg = UnitConversionUtils.parseWeight(_weightController.text, _selectedWeightUnit);
}
await ref.read(profileControllerProvider.notifier).completeProfileSetup(
userId: userId,
username: _usernameController.text.trim(),
@@ -169,6 +189,12 @@ class _ProfileSetupScreenState extends ConsumerState<ProfileSetupScreen> {
websiteUrl: _websiteController.text.trim().isEmpty
? null
: _websiteController.text.trim(),
gender: _selectedGender,
birthDate: _selectedBirthDate,
heightCm: heightCm,
weightKg: weightKg,
heightUnit: _selectedHeightUnit,
weightUnit: _selectedWeightUnit,
);
if (mounted) {
@@ -195,6 +221,8 @@ class _ProfileSetupScreenState extends ConsumerState<ProfileSetupScreen> {
_instagramController.dispose();
_tiktokController.dispose();
_websiteController.dispose();
_heightController.dispose();
_weightController.dispose();
super.dispose();
}
@@ -341,6 +369,170 @@ class _ProfileSetupScreenState extends ConsumerState<ProfileSetupScreen> {
),
enabled: !_isLoading,
),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 16),
Text(
'Biometric Information (Optional)',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 16),
// Gender Field
DropdownButtonFormField<Gender>(
initialValue: _selectedGender,
decoration: const InputDecoration(
labelText: 'Gender',
prefixIcon: Icon(Icons.person_outline),
border: OutlineInputBorder(),
),
items: Gender.values.map((gender) {
return DropdownMenuItem(
value: gender,
child: Row(
children: [
Text(gender.emoji),
const SizedBox(width: 8),
Text(gender.displayName),
],
),
);
}).toList(),
onChanged: !_isLoading ? (Gender? value) {
setState(() {
_selectedGender = value;
});
} : null,
),
const SizedBox(height: 16),
// Birth Date Field
InkWell(
onTap: !_isLoading ? () async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _selectedBirthDate ?? DateTime.now().subtract(const Duration(days: 365 * 25)),
firstDate: DateTime.now().subtract(const Duration(days: 365 * 120)),
lastDate: DateTime.now().subtract(const Duration(days: 365 * 13)),
);
if (picked != null) {
setState(() {
_selectedBirthDate = picked;
});
}
} : null,
child: InputDecorator(
decoration: const InputDecoration(
labelText: 'Birth Date',
prefixIcon: Icon(Icons.cake_outlined),
border: OutlineInputBorder(),
),
child: Text(
_selectedBirthDate != null
? '${_selectedBirthDate!.day}/${_selectedBirthDate!.month}/${_selectedBirthDate!.year}'
: 'Select your birth date',
style: TextStyle(
color: _selectedBirthDate != null
? null
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
),
),
const SizedBox(height: 16),
// Height and Weight Row
Row(
children: [
Expanded(
child: TextFormField(
controller: _heightController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: 'Height (${_selectedHeightUnit == HeightUnit.metric ? 'cm' : 'ft/in'})',
prefixIcon: const Icon(Icons.height_outlined),
border: const OutlineInputBorder(),
suffixIcon: PopupMenuButton<HeightUnit>(
icon: const Icon(Icons.tune),
onSelected: (HeightUnit unit) {
setState(() {
_selectedHeightUnit = unit;
// Convert existing value if needed
if (_heightController.text.isNotEmpty) {
final currentValue = double.tryParse(_heightController.text);
if (currentValue != null) {
double convertedValue;
if (unit == HeightUnit.imperial && _selectedHeightUnit == HeightUnit.metric) {
convertedValue = UnitConversionUtils.cmToInches(currentValue);
_heightController.text = convertedValue.toStringAsFixed(1);
} else if (unit == HeightUnit.metric && _selectedHeightUnit == HeightUnit.imperial) {
convertedValue = UnitConversionUtils.inchesToCm(currentValue);
_heightController.text = convertedValue.toStringAsFixed(1);
}
}
}
});
},
itemBuilder: (context) => [
const PopupMenuItem(value: HeightUnit.metric, child: Text('Metric (cm)')),
const PopupMenuItem(value: HeightUnit.imperial, child: Text('Imperial (ft/in)')),
],
),
),
enabled: !_isLoading,
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _weightController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: 'Weight (${_selectedWeightUnit == WeightUnit.metric ? 'kg' : 'lbs'})',
prefixIcon: const Icon(Icons.monitor_weight_outlined),
border: const OutlineInputBorder(),
suffixIcon: PopupMenuButton<WeightUnit>(
icon: const Icon(Icons.tune),
onSelected: (WeightUnit unit) {
setState(() {
_selectedWeightUnit = unit;
// Convert existing value if needed
if (_weightController.text.isNotEmpty) {
final currentValue = double.tryParse(_weightController.text);
if (currentValue != null) {
double convertedValue;
if (unit == WeightUnit.imperial && _selectedWeightUnit == WeightUnit.metric) {
convertedValue = UnitConversionUtils.kgToLbs(currentValue);
_weightController.text = convertedValue.toStringAsFixed(1);
} else if (unit == WeightUnit.metric && _selectedWeightUnit == WeightUnit.imperial) {
convertedValue = UnitConversionUtils.lbsToKg(currentValue);
_weightController.text = convertedValue.toStringAsFixed(1);
}
}
}
});
},
itemBuilder: (context) => [
const PopupMenuItem(value: WeightUnit.metric, child: Text('Metric (kg)')),
const PopupMenuItem(value: WeightUnit.imperial, child: Text('Imperial (lbs)')),
],
),
),
enabled: !_isLoading,
),
),
],
),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 16),
Text(
'Social Links (Optional)',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 16),
TextFormField(
controller: _twitterController,
@@ -5,7 +5,11 @@ import '../../../bootstrap/supabase_client.dart';
import '../../auth/application/auth_controller.dart';
final userRepositoryProvider = Provider<UserRepository>((ref) {
return UserRepository(supabaseClient);
final client = supabaseClient;
if (client == null) {
throw Exception('Supabase not initialized - user repository unavailable');
}
return UserRepository(client);
});
final notificationsRepositoryProvider = Provider<NotificationsRepository>((ref) {
@@ -202,13 +202,14 @@ class AboutChallengeScreen extends StatelessWidget {
Future<void> _openLink(BuildContext context, String url) async {
final uri = Uri.parse(url);
final scaffoldMessenger = ScaffoldMessenger.of(context);
final launched = await launchUrl(
uri,
mode: LaunchMode.externalApplication,
);
if (!launched) {
ScaffoldMessenger.of(context).showSnackBar(
scaffoldMessenger.showSnackBar(
const SnackBar(content: Text('Could not open link')),
);
}
@@ -0,0 +1,347 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/widgets/app_scaffold.dart';
import '../../../data/services/biometric_service.dart';
import '../../auth/application/auth_controller.dart';
import 'package:local_auth/local_auth.dart' as local_auth;
class BiometricSettingsScreen extends ConsumerStatefulWidget {
const BiometricSettingsScreen({super.key});
@override
ConsumerState<BiometricSettingsScreen> createState() => _BiometricSettingsScreenState();
}
class _BiometricSettingsScreenState extends ConsumerState<BiometricSettingsScreen> {
final BiometricService _biometricService = BiometricService();
bool _isLoading = false;
BiometricAvailability? _availability;
bool _isEnabled = false;
String _statusMessage = '';
List<local_auth.BiometricType> _availableBiometrics = [];
local_auth.BiometricType? _primaryBiometricType;
@override
void initState() {
super.initState();
_loadBiometricStatus();
}
Future<void> _loadBiometricStatus() async {
setState(() => _isLoading = true);
try {
final availability = await _biometricService.checkAvailability();
final isEnabled = await _biometricService.isBiometricEnabled();
final statusMessage = await _biometricService.getBiometricStatusMessage();
final availableBiometrics = await _biometricService.getAvailableBiometrics();
final primaryBiometricType = await _biometricService.getPrimaryBiometricType();
setState(() {
_availability = availability;
_isEnabled = isEnabled;
_statusMessage = statusMessage;
_availableBiometrics = availableBiometrics;
_primaryBiometricType = primaryBiometricType;
_isLoading = false;
});
} catch (e) {
setState(() => _isLoading = false);
}
}
Future<void> _toggleBiometric() async {
if (_availability != BiometricAvailability.available) {
return;
}
setState(() => _isLoading = true);
try {
final authController = ref.read(authControllerProvider.notifier);
if (_isEnabled) {
// Disable biometric
final success = await authController.disableBiometric();
if (success) {
setState(() => _isEnabled = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Biometric login disabled')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to disable biometric login')),
);
}
} else {
// Enable biometric
final success = await authController.enableBiometric();
if (success) {
setState(() => _isEnabled = true);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Biometric login enabled successfully')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to enable biometric login')),
);
}
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: ${e.toString()}')),
);
} finally {
setState(() => _isLoading = false);
}
}
Future<void> _testBiometric() async {
if (!_isEnabled) return;
setState(() => _isLoading = true);
try {
final authController = ref.read(authControllerProvider.notifier);
final success = await authController.signInWithBiometric();
if (success) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Biometric login successful')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Biometric login failed')),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: ${e.toString()}')),
);
} finally {
setState(() => _isLoading = false);
}
}
String _getBiometricTypeEmoji(local_auth.BiometricType type) {
switch (type) {
case local_auth.BiometricType.fingerprint:
return '👆';
case local_auth.BiometricType.face:
return '👤';
case local_auth.BiometricType.iris:
return '👁️';
default:
return '🔒';
}
}
String _getBiometricTypeName(local_auth.BiometricType type) {
switch (type) {
case local_auth.BiometricType.fingerprint:
return 'Fingerprint';
case local_auth.BiometricType.face:
return 'Face ID';
case local_auth.BiometricType.iris:
return 'Iris Scanner';
default:
return 'Biometric';
}
}
Color _getStatusColor() {
switch (_availability) {
case BiometricAvailability.available:
return _isEnabled ? Colors.green : Colors.orange;
case BiometricAvailability.notAvailable:
return Colors.grey;
case BiometricAvailability.notEnrolled:
return Colors.orange;
case BiometricAvailability.lockedOut:
return Colors.red;
case BiometricAvailability.permanentlyUnavailable:
return Colors.red;
case null:
return Colors.grey;
}
}
@override
Widget build(BuildContext context) {
return AppScaffold(
title: 'Biometric Login',
body: _isLoading
? const Center(child: CircularProgressIndicator())
: ListView(
padding: const EdgeInsets.all(16),
children: [
// Status Card
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
_primaryBiometricType != null
? Icons.fingerprint
: Icons.lock,
size: 32,
color: _getStatusColor(),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Biometric Status',
style: Theme.of(context).textTheme.titleMedium,
),
Text(
_statusMessage,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: _getStatusColor(),
),
),
],
),
),
],
),
const SizedBox(height: 16),
if (_availability == BiometricAvailability.available)
SwitchListTile(
title: const Text('Enable Biometric Login'),
subtitle: const Text('Use fingerprint or face ID for quick access'),
value: _isEnabled,
onChanged: (value) => _toggleBiometric(),
secondary: Icon(
_isEnabled ? Icons.lock_open : Icons.lock,
color: _getStatusColor(),
),
),
],
),
),
),
const SizedBox(height: 16),
// Available Biometrics
if (_availableBiometrics.isNotEmpty)
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Available Biometrics',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
..._availableBiometrics.map((type) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Text(
_getBiometricTypeEmoji(type),
style: const TextStyle(fontSize: 24),
),
const SizedBox(width: 12),
Text(
_getBiometricTypeName(type),
style: Theme.of(context).textTheme.bodyLarge,
),
const Spacer(),
if (type == _primaryBiometricType)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'Primary',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onPrimary,
),
),
),
],
),
)),
],
),
),
),
const SizedBox(height: 16),
// Test Biometric (if enabled)
if (_isEnabled)
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Test Biometric Login',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'Test your biometric authentication to make sure it\'s working properly.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _testBiometric,
icon: const Icon(Icons.fingerprint),
label: const Text('Test Biometric Login'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
],
),
),
),
const SizedBox(height: 16),
// Information Card
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'About Biometric Login',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
Text(
'• Biometric login allows you to sign in quickly using your fingerprint or face ID.\n'
'• Your biometric data is stored securely on your device and never sent to our servers.\n'
'• You can disable biometric login at any time in these settings.\n'
'• If you change your password, you may need to re-enable biometric login.',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
],
),
);
}
}
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
import '../../../bootstrap/supabase_client.dart';
import '../../../core/widgets/app_scaffold.dart';
class SettingsHomeScreen extends ConsumerWidget {
@@ -10,6 +10,7 @@ class SettingsHomeScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return AppScaffold(
title: 'Settings',
body: ListView(
children: [
_buildSection(
@@ -25,7 +26,7 @@ class SettingsHomeScreen extends ConsumerWidget {
_SettingsTile(
icon: Icons.email,
title: 'Email',
subtitle: supabase.Supabase.instance.client.auth.currentUser?.email ?? '',
subtitle: currentSupabaseUserEmail ?? 'Not signed in',
onTap: () => context.push('/settings/account'),
),
_SettingsTile(
@@ -64,6 +65,12 @@ class SettingsHomeScreen extends ConsumerWidget {
subtitle: 'Public or Private profile',
onTap: () => context.push('/settings/privacy'),
),
_SettingsTile(
icon: Icons.fingerprint,
title: 'Biometric Login',
subtitle: 'Use fingerprint or face ID for quick access',
onTap: () => context.push('/settings/biometric'),
),
_SettingsTile(
icon: Icons.block,
title: 'Blocked Users',
@@ -145,7 +145,7 @@ class SocialState {
}
final socialRepositoryProvider = Provider<SocialRepository>((ref) {
return SocialRepository(supabaseClient);
return SocialRepository(supabaseClient ?? (throw Exception("Supabase not initialized")));
});
final socialControllerProvider = StateNotifierProvider<SocialController, SocialState>((ref) {
@@ -162,7 +162,7 @@ final socialNotificationsControllerProvider =
});
final socialRepositoryProvider = Provider<SocialRepository>((ref) {
return SocialRepository(supabaseClient);
return SocialRepository(supabaseClient ?? (throw Exception("Supabase not initialized")));
});
final notificationsRepositoryProvider = Provider<NotificationsRepository>((ref) {
@@ -1,10 +1,13 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
import '../../../core/widgets/app_scaffold.dart';
import '../../../core/widgets/loading_indicator.dart';
import '../../../core/utils/date_time_utils.dart';
import '../../../core/utils/unit_conversion_utils.dart';
import '../../../data/models/user_model.dart' as app;
import '../../../data/models/goal_model.dart';
import '../../auth/application/auth_controller.dart';
import '../application/social_controller.dart';
import '../../profile/application/profile_controller.dart';
@@ -22,12 +25,19 @@ class PublicProfileScreen extends ConsumerStatefulWidget {
}
class _PublicProfileScreenState extends ConsumerState<PublicProfileScreen> {
Map<String, dynamic>? _userStats;
List<Goal>? _userGoals;
bool _isLoadingStats = false;
bool _isLoadingGoals = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadProfile();
_checkFollowingStatus();
_loadUserStats();
_loadUserGoals();
});
}
@@ -39,6 +49,37 @@ class _PublicProfileScreenState extends ConsumerState<PublicProfileScreen> {
await ref.read(socialControllerProvider.notifier).isFollowing(widget.userId);
}
Future<void> _loadUserStats() async {
setState(() => _isLoadingStats = true);
try {
final client = supabase.Supabase.instance.client;
final response = await client.rpc('get_user_stats', params: {'user_uuid': widget.userId});
setState(() {
_userStats = response as Map<String, dynamic>;
_isLoadingStats = false;
});
} catch (e) {
setState(() => _isLoadingStats = false);
}
}
Future<void> _loadUserGoals() async {
setState(() => _isLoadingGoals = true);
try {
final client = supabase.Supabase.instance.client;
final response = await client.rpc('get_public_user_goals', params: {
'user_uuid': widget.userId,
'limit_count': 10
});
setState(() {
_userGoals = (response as List).map((json) => Goal.fromJson(json)).toList();
_isLoadingGoals = false;
});
} catch (e) {
setState(() => _isLoadingGoals = false);
}
}
Future<void> _toggleFollow() async {
final controller = ref.read(socialControllerProvider.notifier);
final isFollowing = await controller.isFollowing(widget.userId);
@@ -90,6 +131,8 @@ class _PublicProfileScreenState extends ConsumerState<PublicProfileScreen> {
onRefresh: () async {
await _loadProfile();
await _checkFollowingStatus();
await _loadUserStats();
await _loadUserGoals();
},
child: CustomScrollView(
slivers: [
@@ -101,7 +144,22 @@ class _PublicProfileScreenState extends ConsumerState<PublicProfileScreen> {
),
),
SliverToBoxAdapter(
child: _StatsSection(user: user),
child: _EnhancedStatsSection(
user: user,
userStats: _userStats,
isLoadingStats: _isLoadingStats,
),
),
if (_userGoals != null && _userGoals!.isNotEmpty)
SliverToBoxAdapter(
child: _UserGoalsSection(
goals: _userGoals!,
isLoadingGoals: _isLoadingGoals,
),
),
if (user.age != null || user.formattedHeight.isNotEmpty || user.formattedWeight.isNotEmpty)
SliverToBoxAdapter(
child: _BiometricSection(user: user),
),
],
),
@@ -109,6 +167,311 @@ class _PublicProfileScreenState extends ConsumerState<PublicProfileScreen> {
}
}
class _EnhancedStatsSection extends StatelessWidget {
final app.User user;
final Map<String, dynamic>? userStats;
final bool isLoadingStats;
const _EnhancedStatsSection({
required this.user,
this.userStats,
required this.isLoadingStats,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Profile Stats',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
if (isLoadingStats)
const Center(child: CircularProgressIndicator())
else
Row(
children: [
Expanded(
child: _StatCard(
icon: Icons.flag,
title: 'Goals',
value: '${userStats?['goals_count'] ?? 0}',
),
),
const SizedBox(width: 12),
Expanded(
child: _StatCard(
icon: Icons.check_circle,
title: 'Completed',
value: '${userStats?['completed_goals_count'] ?? 0}',
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _StatCard(
icon: Icons.people,
title: 'Followers',
value: '${userStats?['followers_count'] ?? 0}',
),
),
const SizedBox(width: 12),
Expanded(
child: _StatCard(
icon: Icons.person_add,
title: 'Following',
value: '${userStats?['following_count'] ?? 0}',
),
),
],
),
const SizedBox(height: 16),
if (user.countdownStartDate != null) ...[
_StatCard(
icon: Icons.timer,
title: 'Challenge Started',
value: DateTimeUtils.formatShortDate(user.countdownStartDate!),
),
const SizedBox(height: 12),
if (user.daysRemaining != null)
_StatCard(
icon: Icons.hourglass_empty,
title: 'Days Remaining',
value: '${user.daysRemaining} days',
),
const SizedBox(height: 12),
],
_StatCard(
icon: Icons.calendar_today,
title: 'Member Since',
value: DateTimeUtils.formatShortDate(user.createdAt),
),
],
),
);
}
}
class _UserGoalsSection extends StatelessWidget {
final List<Goal> goals;
final bool isLoadingGoals;
const _UserGoalsSection({
required this.goals,
required this.isLoadingGoals,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Public Goals',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
if (isLoadingGoals)
const Center(child: CircularProgressIndicator())
else
...goals.map((goal) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _GoalCard(goal: goal),
)),
],
),
);
}
}
class _BiometricSection extends StatelessWidget {
final app.User user;
const _BiometricSection({required this.user});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Biometric Information',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Row(
children: [
if (user.gender != null)
Expanded(
child: _BiometricCard(
icon: user.gender!.emoji,
title: 'Gender',
value: user.gender!.displayName,
),
),
if (user.age != null)
Expanded(
child: _BiometricCard(
icon: '🎂',
title: 'Age',
value: '${user.age} years',
),
),
],
),
if (user.gender != null && user.age != null)
const SizedBox(height: 12),
Row(
children: [
if (user.formattedHeight.isNotEmpty)
Expanded(
child: _BiometricCard(
icon: '📏',
title: 'Height',
value: user.formattedHeight,
),
),
if (user.formattedWeight.isNotEmpty)
Expanded(
child: _BiometricCard(
icon: '⚖️',
title: 'Weight',
value: user.formattedWeight,
),
),
],
),
if (user.bmi != null)
Column(
children: [
const SizedBox(height: 12),
_BiometricCard(
icon: '💪',
title: 'BMI',
value: '${user.bmi!.toStringAsFixed(1)} - ${user.bmiCategory}',
valueColor: UnitConversionUtils.getBmiColor(user.bmi!),
),
],
),
],
),
);
}
}
class _BiometricCard extends StatelessWidget {
final String icon;
final String title;
final String value;
final Color? valueColor;
const _BiometricCard({
required this.icon,
required this.title,
required this.value,
this.valueColor,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Row(
children: [
Text(
icon,
style: const TextStyle(fontSize: 20),
),
const SizedBox(width: 8),
Expanded(
child: Text(
value,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: valueColor ?? null,
),
),
),
],
),
],
),
);
}
}
class _GoalCard extends StatelessWidget {
final Goal goal;
const _GoalCard({required this.goal});
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
leading: CircleAvatar(
backgroundColor: goal.completed
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Icon(
goal.completed ? Icons.check : Icons.flag_outlined,
color: goal.completed
? Theme.of(context).colorScheme.onPrimaryContainer
: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
title: Text(
goal.title,
style: TextStyle(
decoration: goal.completed ? TextDecoration.lineThrough : null,
),
),
subtitle: goal.description != null && goal.description!.isNotEmpty
? Text(
goal.description!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
)
: null,
trailing: goal.progress > 0
? Text('${goal.progress}%')
: null,
),
);
}
}
class _ProfileHeader extends ConsumerWidget {
final app.User user;
final bool isOwnProfile;
@@ -219,51 +582,6 @@ class _FollowButton extends StatelessWidget {
}
}
class _StatsSection extends StatelessWidget {
final app.User user;
const _StatsSection({required this.user});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Journey Stats',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
if (user.countdownStartDate != null) ...[
_StatCard(
icon: Icons.timer,
title: 'Challenge Started',
value: DateTimeUtils.formatShortDate(user.countdownStartDate!),
),
const SizedBox(height: 12),
if (user.daysRemaining != null)
_StatCard(
icon: Icons.hourglass_empty,
title: 'Days Remaining',
value: '${user.daysRemaining} days',
),
const SizedBox(height: 12),
],
_StatCard(
icon: Icons.calendar_today,
title: 'Member Since',
value: DateTimeUtils.formatShortDate(user.createdAt),
),
],
),
);
}
}
class _StatCard extends StatelessWidget {
final IconData icon;
final String title;
+2 -1
View File
@@ -17,6 +17,7 @@ void main() async {
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.transparent,
systemNavigationBarDividerColor: Colors.transparent,
),
);
@@ -42,7 +43,7 @@ class LifeTimerApp extends ConsumerWidget {
final themeMode = ref.watch(themeModeProvider);
return MaterialApp.router(
title: 'LifeTimer',
title: '1356',
debugShowCheckedModeBanner: false,
theme: AppTheme.light,
darkTheme: AppTheme.dark,
+57 -1
View File
@@ -427,6 +427,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.2.0"
flutter_markdown:
dependency: "direct main"
description:
name: flutter_markdown
sha256: "08fb8315236099ff8e90cb87bb2b935e0a724a3af1623000a9cec930468e0f27"
url: "https://pub.dev"
source: hosted
version: "0.7.7+1"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
@@ -879,6 +887,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.0"
local_auth:
dependency: "direct main"
description:
name: local_auth
sha256: "434d854cf478f17f12ab29a76a02b3067f86a63a6d6c4eb8fbfdcfe4879c1b7b"
url: "https://pub.dev"
source: hosted
version: "2.3.0"
local_auth_android:
dependency: transitive
description:
name: local_auth_android
sha256: a0bdfcc0607050a26ef5b31d6b4b254581c3d3ce3c1816ab4d4f4a9173e84467
url: "https://pub.dev"
source: hosted
version: "1.0.56"
local_auth_darwin:
dependency: transitive
description:
name: local_auth_darwin
sha256: "699873970067a40ef2f2c09b4c72eb1cfef64224ef041b3df9fdc5c4c1f91f49"
url: "https://pub.dev"
source: hosted
version: "1.6.1"
local_auth_platform_interface:
dependency: transitive
description:
name: local_auth_platform_interface
sha256: f98b8e388588583d3f781f6806e4f4c9f9e189d898d27f0c249b93a1973dd122
url: "https://pub.dev"
source: hosted
version: "1.1.0"
local_auth_windows:
dependency: transitive
description:
name: local_auth_windows
sha256: bc4e66a29b0fdf751aafbec923b5bed7ad6ed3614875d8151afe2578520b2ab5
url: "https://pub.dev"
source: hosted
version: "1.0.11"
logging:
dependency: transitive
description:
@@ -887,6 +935,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.0"
markdown:
dependency: transitive
description:
name: markdown
sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1"
url: "https://pub.dev"
source: hosted
version: "7.3.0"
matcher:
dependency: transitive
description:
@@ -1397,7 +1453,7 @@ packages:
source: hosted
version: "1.12.1"
state_notifier:
dependency: transitive
dependency: "direct main"
description:
name: state_notifier
sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
+3
View File
@@ -14,6 +14,7 @@ dependencies:
# State Management
flutter_riverpod: ^2.4.9
riverpod_annotation: ^2.3.3
state_notifier: ^1.0.0
# Supabase Backend
supabase_flutter: ^2.0.0
@@ -43,6 +44,7 @@ dependencies:
http: ^1.1.0
url_launcher: ^6.1.10
home_widget: ^0.7.0
local_auth: ^2.1.7
# Image Handling
cached_network_image: ^3.3.0
@@ -61,6 +63,7 @@ dependencies:
# AI & Voice
record: ^6.1.2
permission_handler: ^11.0.1
flutter_markdown: ^0.7.3
# Testing
mockito: ^5.4.4
@@ -3,10 +3,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:lifetimer/features/auth/application/auth_controller.dart';
import 'package:lifetimer/features/auth/presentation/auth_gate.dart';
import 'package:lifetimer/features/auth/presentation/auth_choice_screen.dart';
import 'package:lifetimer/features/auth/presentation/auth_showcase_screen.dart';
import 'package:lifetimer/features/onboarding/presentation/onboarding_intro_screen.dart';
import 'package:lifetimer/features/onboarding/application/onboarding_controller.dart';
import 'package:lifetimer/data/models/user_model.dart';
import 'package:lifetimer/data/repositories/auth_repository.dart';
import 'package:lifetimer/core/utils/unit_conversion_utils.dart';
class MockAuthRepository extends AuthRepository {
bool _isAuthenticated = false;
@@ -29,13 +31,14 @@ class MockAuthRepository extends AuthRepository {
Future<void> signInWithEmail(String email, String password) async {}
@override
Future<void> signUpWithEmail(String email, String password, String username) async {}
Future<void> signUpWithEmail(String email, String password, String username, {double? heightCm, double? weightKg, int? age, Gender? gender, HeightUnit? heightUnit, WeightUnit? weightUnit}) async {}
@override
Future<void> signInWithGoogle() async {}
@override
Future<void> signInWithApple() async {}
Future<void> signInWithApple() async {
// Mock implementation
}
@override
Future<void> signOut() async {}
@@ -55,12 +58,27 @@ class MockAuthRepository extends AuthRepository {
String? bio,
String? avatarUrl,
bool? isPublicProfile,
double? heightCm,
double? weightKg,
int? age,
Gender? gender,
HeightUnit? heightUnit,
WeightUnit? weightUnit,
}) async {}
@override
void dispose() {}
}
class MockOnboardingController extends OnboardingController {
MockOnboardingController() : super();
@override
Future<void> loadOnboardingStatus() async {
// Do nothing in test
}
}
class TestData {
static User createTestUser() {
return User(
@@ -75,23 +93,28 @@ class TestData {
void main() {
group('AuthGate Widget', () {
testWidgets('should show AuthChoiceScreen when user is not authenticated',
testWidgets('should continue to onboarding when backend is unavailable and user is not authenticated',
(WidgetTester tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
authRepositoryProvider.overrideWithValue(MockAuthRepository()),
onboardingControllerProvider.overrideWith((ref) => MockOnboardingController()),
],
child: const MaterialApp(
home: AuthGate(),
child: MaterialApp(
home: const AuthGate(),
builder: (context, child) => MediaQuery(
data: const MediaQueryData(size: Size(800, 600)),
child: child!,
),
),
),
);
await tester.pumpAndSettle();
expect(find.byType(AuthChoiceScreen), findsOneWidget);
expect(find.byType(OnboardingIntroScreen), findsNothing);
expect(find.byType(OnboardingIntroScreen), findsOneWidget);
expect(find.byType(AuthShowcaseScreen), findsNothing);
});
testWidgets('should show OnboardingIntroScreen when user is authenticated',
@@ -103,9 +126,14 @@ void main() {
ProviderScope(
overrides: [
authRepositoryProvider.overrideWithValue(mockRepo),
onboardingControllerProvider.overrideWith((ref) => MockOnboardingController()),
],
child: const MaterialApp(
home: AuthGate(),
child: MaterialApp(
home: const AuthGate(),
builder: (context, child) => MediaQuery(
data: const MediaQueryData(size: Size(800, 600)),
child: child!,
),
),
),
);
@@ -113,7 +141,7 @@ void main() {
await tester.pumpAndSettle();
expect(find.byType(OnboardingIntroScreen), findsOneWidget);
expect(find.byType(AuthChoiceScreen), findsNothing);
expect(find.byType(AuthShowcaseScreen), findsNothing);
});
});
}
@@ -58,9 +58,13 @@ void main() {
testWidgets('should validate username field', (WidgetTester tester) async {
await tester.pumpWidget(
const ProviderScope(
child: const MaterialApp(
home: SignUpScreen(),
ProviderScope(
child: MaterialApp(
home: const SignUpScreen(),
builder: (context, child) => MediaQuery(
data: const MediaQueryData(size: Size(400, 1200)),
child: child!,
),
),
),
);
@@ -73,7 +77,7 @@ void main() {
await tester.pumpAndSettle();
// Try to submit
final signUpButton = find.text('Sign Up');
final signUpButton = find.text('Create Account');
await tester.tap(signUpButton);
await tester.pumpAndSettle();
@@ -83,9 +87,13 @@ void main() {
testWidgets('should validate email field', (WidgetTester tester) async {
await tester.pumpWidget(
const ProviderScope(
child: const MaterialApp(
home: SignUpScreen(),
ProviderScope(
child: MaterialApp(
home: const SignUpScreen(),
builder: (context, child) => MediaQuery(
data: const MediaQueryData(size: Size(400, 1200)),
child: child!,
),
),
),
);
@@ -98,7 +106,7 @@ void main() {
await tester.pumpAndSettle();
// Try to submit
final signUpButton = find.text('Sign Up');
final signUpButton = find.text('Create Account');
await tester.tap(signUpButton);
await tester.pumpAndSettle();
@@ -108,9 +116,13 @@ void main() {
testWidgets('should validate password field', (WidgetTester tester) async {
await tester.pumpWidget(
const ProviderScope(
child: const MaterialApp(
home: SignUpScreen(),
ProviderScope(
child: MaterialApp(
home: const SignUpScreen(),
builder: (context, child) => MediaQuery(
data: const MediaQueryData(size: Size(400, 1200)),
child: child!,
),
),
),
);
@@ -123,7 +135,7 @@ void main() {
await tester.pumpAndSettle();
// Try to submit
final signUpButton = find.text('Sign Up');
final signUpButton = find.text('Create Account');
await tester.tap(signUpButton);
await tester.pumpAndSettle();
@@ -134,9 +146,13 @@ void main() {
testWidgets('should toggle password visibility',
(WidgetTester tester) async {
await tester.pumpWidget(
const ProviderScope(
child: const MaterialApp(
home: SignUpScreen(),
ProviderScope(
child: MaterialApp(
home: const SignUpScreen(),
builder: (context, child) => MediaQuery(
data: const MediaQueryData(size: Size(400, 800)), // Smaller height
child: child!,
),
),
),
);
@@ -144,44 +160,56 @@ void main() {
await tester.pumpAndSettle();
// Find password visibility toggle button
final toggleButton = find.byIcon(Icons.visibility_off);
final toggleButton = find.byIcon(Icons.visibility_off_outlined);
expect(toggleButton, findsOneWidget);
await tester.tap(toggleButton);
await tester.pumpAndSettle();
// Should now show visibility icon
expect(find.byIcon(Icons.visibility), findsOneWidget);
expect(find.byIcon(Icons.visibility_outlined), findsOneWidget);
});
testWidgets('should show Google sign up button',
testWidgets('should show email sign up form',
(WidgetTester tester) async {
await tester.pumpWidget(
const ProviderScope(
child: const MaterialApp(
home: SignUpScreen(),
ProviderScope(
child: MaterialApp(
home: const SignUpScreen(),
builder: (context, child) => MediaQuery(
data: const MediaQueryData(size: Size(400, 1200)), // Taller for form
child: child!,
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Sign up with Google'), findsOneWidget);
expect(find.text('Create Account'), findsOneWidget);
expect(find.byType(TextFormField), findsNWidgets(6)); // email, password, confirm, username, height, weight
});
testWidgets('should show Apple sign up button',
testWidgets('should show email sign up form only',
(WidgetTester tester) async {
await tester.pumpWidget(
const ProviderScope(
child: const MaterialApp(
home: SignUpScreen(),
ProviderScope(
child: MaterialApp(
home: const SignUpScreen(),
builder: (context, child) => MediaQuery(
data: const MediaQueryData(size: Size(400, 1200)),
child: child!,
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Sign up with Apple'), findsOneWidget);
// Should not have social sign-up buttons
expect(find.text('Sign up with Google'), findsNothing);
expect(find.text('Sign up with Apple'), findsNothing);
expect(find.text('Create Account'), findsOneWidget);
});
});
}
@@ -18,7 +18,7 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('Your Journey Awaits'), findsOneWidget);
expect(find.text('Your Time is Now'), findsOneWidget);
});
testWidgets('should display motivational message',
@@ -33,11 +33,11 @@ void main() {
await tester.pumpAndSettle();
expect(find.textContaining('goals'), findsOneWidget);
expect(find.textContaining('1356 days'), findsOneWidget);
expect(find.textContaining('dreams'), findsOneWidget);
});
testWidgets('should display start challenge button',
testWidgets('should display get started button',
(WidgetTester tester) async {
await tester.pumpWidget(
const ProviderScope(
@@ -49,7 +49,7 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('Start Your Challenge'), findsOneWidget);
expect(find.text('Get Started'), findsOneWidget);
});
testWidgets('should display back button', (WidgetTester tester) async {
@@ -77,8 +77,8 @@ void main() {
await tester.pumpAndSettle();
// Should have step indicators
expect(find.byType(Container), findsWidgets);
expect(find.text('Step 3 of 3'), findsOneWidget);
expect(find.byType(LinearProgressIndicator), findsOneWidget);
});
});
}
@@ -21,7 +21,7 @@ void main() {
expect(find.text('Profile'), findsOneWidget);
});
testWidgets('should display user avatar', (WidgetTester tester) async {
testWidgets('should display signed out state', (WidgetTester tester) async {
await tester.pumpWidget(
const ProviderScope(
child: const MaterialApp(
@@ -32,10 +32,11 @@ void main() {
await tester.pumpAndSettle();
expect(find.byType(CircleAvatar), findsOneWidget);
expect(find.text('Not Signed In'), findsOneWidget);
expect(find.text('Please sign in to view your profile'), findsOneWidget);
});
testWidgets('should display username', (WidgetTester tester) async {
testWidgets('should display signed out icon', (WidgetTester tester) async {
await tester.pumpWidget(
const ProviderScope(
child: const MaterialApp(
@@ -46,81 +47,7 @@ void main() {
await tester.pumpAndSettle();
// Should display username section
expect(find.textContaining('Username'), findsOneWidget);
});
testWidgets('should display countdown information',
(WidgetTester tester) async {
await tester.pumpWidget(
const ProviderScope(
child: const MaterialApp(
home: ProfileScreen(),
),
),
);
await tester.pumpAndSettle();
expect(find.textContaining('Days Left'), findsOneWidget);
});
testWidgets('should display goals completed stat',
(WidgetTester tester) async {
await tester.pumpWidget(
const ProviderScope(
child: const MaterialApp(
home: ProfileScreen(),
),
),
);
await tester.pumpAndSettle();
expect(find.textContaining('Goals Completed'), findsOneWidget);
});
testWidgets('should display edit profile button',
(WidgetTester tester) async {
await tester.pumpWidget(
const ProviderScope(
child: const MaterialApp(
home: ProfileScreen(),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Edit Profile'), findsOneWidget);
});
testWidgets('should display settings button', (WidgetTester tester) async {
await tester.pumpWidget(
const ProviderScope(
child: const MaterialApp(
home: ProfileScreen(),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Settings'), findsOneWidget);
});
testWidgets('should display sign out button', (WidgetTester tester) async {
await tester.pumpWidget(
const ProviderScope(
child: const MaterialApp(
home: ProfileScreen(),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Sign Out'), findsOneWidget);
expect(find.byIcon(Icons.person_off), findsOneWidget);
});
});
}
@@ -73,12 +73,18 @@ void main() {
),
);
await tester.pumpAndSettle();
await tester.scrollUntilVisible(
find.text('About the Challenge'),
300,
scrollable: find.byType(Scrollable),
);
await tester.pumpAndSettle();
expect(find.text('About'), findsOneWidget);
expect(find.text('About the Challenge'), findsOneWidget);
});
testWidgets('should display account settings option',
testWidgets('should display edit profile option',
(WidgetTester tester) async {
await tester.pumpWidget(
const ProviderScope(
@@ -90,7 +96,7 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('Account Settings'), findsOneWidget);
expect(find.text('Edit Profile'), findsOneWidget);
});
testWidgets('should display notification settings option',
@@ -108,7 +114,7 @@ void main() {
expect(find.text('Notifications'), findsOneWidget);
});
testWidgets('should display privacy settings option',
testWidgets('should display profile visibility option',
(WidgetTester tester) async {
await tester.pumpWidget(
const ProviderScope(
@@ -118,9 +124,15 @@ void main() {
),
);
await tester.pumpAndSettle();
await tester.scrollUntilVisible(
find.text('Profile Visibility'),
300,
scrollable: find.byType(Scrollable),
);
await tester.pumpAndSettle();
expect(find.text('Privacy Settings'), findsOneWidget);
expect(find.text('Profile Visibility'), findsOneWidget);
});
testWidgets('should display about challenge option',
@@ -133,6 +145,12 @@ void main() {
),
);
await tester.pumpAndSettle();
await tester.scrollUntilVisible(
find.text('About the Challenge'),
300,
scrollable: find.byType(Scrollable),
);
await tester.pumpAndSettle();
expect(find.text('About the Challenge'), findsOneWidget);
+15
View File
@@ -0,0 +1,15 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:hive/hive.dart';
import 'package:shared_preferences/shared_preferences.dart';
Future<void> testExecutable(dynamic Function() testMain) async {
TestWidgetsFlutterBinding.ensureInitialized();
SharedPreferences.setMockInitialValues({});
final hiveDir = await Directory.systemTemp.createTemp('lifetimer_test_hive_');
Hive.init(hiveDir.path);
await Future<void>.sync(() => testMain());
}
@@ -6,18 +6,19 @@
import 'dart:async' as _i7;
import 'package:flutter_local_notifications/flutter_local_notifications.dart'
as _i14;
as _i15;
import 'package:lifetimer/core/utils/unit_conversion_utils.dart' as _i9;
import 'package:lifetimer/data/models/activity_model.dart' as _i5;
import 'package:lifetimer/data/models/goal_model.dart' as _i2;
import 'package:lifetimer/data/models/goal_step_model.dart' as _i3;
import 'package:lifetimer/data/models/user_model.dart' as _i4;
import 'package:lifetimer/data/repositories/auth_repository.dart' as _i6;
import 'package:lifetimer/data/repositories/countdown_repository.dart' as _i10;
import 'package:lifetimer/data/repositories/goals_repository.dart' as _i9;
import 'package:lifetimer/data/repositories/countdown_repository.dart' as _i11;
import 'package:lifetimer/data/repositories/goals_repository.dart' as _i10;
import 'package:lifetimer/data/repositories/notifications_repository.dart'
as _i13;
import 'package:lifetimer/data/repositories/social_repository.dart' as _i12;
import 'package:lifetimer/data/repositories/user_repository.dart' as _i11;
as _i14;
import 'package:lifetimer/data/repositories/social_repository.dart' as _i13;
import 'package:lifetimer/data/repositories/user_repository.dart' as _i12;
import 'package:mockito/mockito.dart' as _i1;
import 'package:supabase_flutter/supabase_flutter.dart' as _i8;
@@ -162,8 +163,14 @@ class MockAuthRepository extends _i1.Mock implements _i6.AuthRepository {
_i7.Future<void> signUpWithEmail(
String? email,
String? password,
String? username,
) =>
String? username, {
double? heightCm,
double? weightKg,
int? age,
_i9.Gender? gender,
_i9.HeightUnit? heightUnit,
_i9.WeightUnit? weightUnit,
}) =>
(super.noSuchMethod(
Invocation.method(
#signUpWithEmail,
@@ -172,6 +179,14 @@ class MockAuthRepository extends _i1.Mock implements _i6.AuthRepository {
password,
username,
],
{
#heightCm: heightCm,
#weightKg: weightKg,
#age: age,
#gender: gender,
#heightUnit: heightUnit,
#weightUnit: weightUnit,
},
),
returnValue: _i7.Future<void>.value(),
returnValueForMissingStub: _i7.Future<void>.value(),
@@ -197,16 +212,6 @@ class MockAuthRepository extends _i1.Mock implements _i6.AuthRepository {
returnValueForMissingStub: _i7.Future<void>.value(),
) as _i7.Future<void>);
@override
_i7.Future<void> signInWithApple() => (super.noSuchMethod(
Invocation.method(
#signInWithApple,
[],
),
returnValue: _i7.Future<void>.value(),
returnValueForMissingStub: _i7.Future<void>.value(),
) as _i7.Future<void>);
@override
_i7.Future<void> signOut() => (super.noSuchMethod(
Invocation.method(
@@ -233,6 +238,12 @@ class MockAuthRepository extends _i1.Mock implements _i6.AuthRepository {
String? bio,
String? avatarUrl,
bool? isPublicProfile,
double? heightCm,
double? weightKg,
int? age,
_i9.Gender? gender,
_i9.HeightUnit? heightUnit,
_i9.WeightUnit? weightUnit,
}) =>
(super.noSuchMethod(
Invocation.method(
@@ -243,6 +254,12 @@ class MockAuthRepository extends _i1.Mock implements _i6.AuthRepository {
#bio: bio,
#avatarUrl: avatarUrl,
#isPublicProfile: isPublicProfile,
#heightCm: heightCm,
#weightKg: weightKg,
#age: age,
#gender: gender,
#heightUnit: heightUnit,
#weightUnit: weightUnit,
},
),
returnValue: _i7.Future<void>.value(),
@@ -253,7 +270,7 @@ class MockAuthRepository extends _i1.Mock implements _i6.AuthRepository {
/// A class which mocks [GoalsRepository].
///
/// See the documentation for Mockito's code generation for more information.
class MockGoalsRepository extends _i1.Mock implements _i9.GoalsRepository {
class MockGoalsRepository extends _i1.Mock implements _i10.GoalsRepository {
MockGoalsRepository() {
_i1.throwOnMissingStub(this);
}
@@ -397,7 +414,7 @@ class MockGoalsRepository extends _i1.Mock implements _i9.GoalsRepository {
///
/// See the documentation for Mockito's code generation for more information.
class MockCountdownRepository extends _i1.Mock
implements _i10.CountdownRepository {
implements _i11.CountdownRepository {
MockCountdownRepository() {
_i1.throwOnMissingStub(this);
}
@@ -445,7 +462,7 @@ class MockCountdownRepository extends _i1.Mock
/// A class which mocks [UserRepository].
///
/// See the documentation for Mockito's code generation for more information.
class MockUserRepository extends _i1.Mock implements _i11.UserRepository {
class MockUserRepository extends _i1.Mock implements _i12.UserRepository {
MockUserRepository() {
_i1.throwOnMissingStub(this);
}
@@ -476,6 +493,12 @@ class MockUserRepository extends _i1.Mock implements _i11.UserRepository {
String? instagramHandle,
String? tiktokHandle,
String? websiteUrl,
_i9.Gender? gender,
DateTime? birthDate,
double? heightCm,
double? weightKg,
_i9.HeightUnit? heightUnit = _i9.HeightUnit.metric,
_i9.WeightUnit? weightUnit = _i9.WeightUnit.metric,
}) =>
(super.noSuchMethod(
Invocation.method(
@@ -491,6 +514,12 @@ class MockUserRepository extends _i1.Mock implements _i11.UserRepository {
#instagramHandle: instagramHandle,
#tiktokHandle: tiktokHandle,
#websiteUrl: websiteUrl,
#gender: gender,
#birthDate: birthDate,
#heightCm: heightCm,
#weightKg: weightKg,
#heightUnit: heightUnit,
#weightUnit: weightUnit,
},
),
returnValue: _i7.Future<_i4.User>.value(_FakeUser_2(
@@ -508,6 +537,12 @@ class MockUserRepository extends _i1.Mock implements _i11.UserRepository {
#instagramHandle: instagramHandle,
#tiktokHandle: tiktokHandle,
#websiteUrl: websiteUrl,
#gender: gender,
#birthDate: birthDate,
#heightCm: heightCm,
#weightKg: weightKg,
#heightUnit: heightUnit,
#weightUnit: weightUnit,
},
),
)),
@@ -536,7 +571,7 @@ class MockUserRepository extends _i1.Mock implements _i11.UserRepository {
/// A class which mocks [SocialRepository].
///
/// See the documentation for Mockito's code generation for more information.
class MockSocialRepository extends _i1.Mock implements _i12.SocialRepository {
class MockSocialRepository extends _i1.Mock implements _i13.SocialRepository {
MockSocialRepository() {
_i1.throwOnMissingStub(this);
}
@@ -673,7 +708,7 @@ class MockSocialRepository extends _i1.Mock implements _i12.SocialRepository {
///
/// See the documentation for Mockito's code generation for more information.
class MockNotificationsRepository extends _i1.Mock
implements _i13.NotificationsRepository {
implements _i14.NotificationsRepository {
MockNotificationsRepository() {
_i1.throwOnMissingStub(this);
}
@@ -769,13 +804,13 @@ class MockNotificationsRepository extends _i1.Mock
) as _i7.Future<void>);
@override
_i7.Future<List<_i14.PendingNotificationRequest>> getPendingNotifications() =>
_i7.Future<List<_i15.PendingNotificationRequest>> getPendingNotifications() =>
(super.noSuchMethod(
Invocation.method(
#getPendingNotifications,
[],
),
returnValue: _i7.Future<List<_i14.PendingNotificationRequest>>.value(
<_i14.PendingNotificationRequest>[]),
) as _i7.Future<List<_i14.PendingNotificationRequest>>);
returnValue: _i7.Future<List<_i15.PendingNotificationRequest>>.value(
<_i15.PendingNotificationRequest>[]),
) as _i7.Future<List<_i15.PendingNotificationRequest>>);
}
+92
View File
@@ -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"