mirror of
https://github.com/Dvorinka/1356.git
synced 2026-06-03 19:42:57 +00:00
small fix, don't worry about it
This commit is contained in:
@@ -11,11 +11,25 @@ Future<void> bootstrap() async {
|
||||
await HomeWidget.setAppGroupId(Env.iosAppGroupId);
|
||||
}
|
||||
|
||||
await Supabase.initialize(
|
||||
url: Env.supabaseUrl,
|
||||
anonKey: Env.supabaseAnonKey,
|
||||
debug: true,
|
||||
);
|
||||
|
||||
initializeSupabaseClient();
|
||||
// Only initialize Supabase if we have valid credentials
|
||||
if (Env.supabaseUrl.isNotEmpty &&
|
||||
Env.supabaseUrl != 'https://your-project.supabase.co' &&
|
||||
Env.supabaseAnonKey.isNotEmpty &&
|
||||
Env.supabaseAnonKey != 'your-anon-key') {
|
||||
try {
|
||||
await Supabase.initialize(
|
||||
url: Env.supabaseUrl,
|
||||
anonKey: Env.supabaseAnonKey,
|
||||
debug: true,
|
||||
);
|
||||
initializeSupabaseClient();
|
||||
} catch (e) {
|
||||
// If Supabase initialization fails, continue without it
|
||||
print('Warning: Supabase initialization failed: $e');
|
||||
print('App will run in offline mode');
|
||||
}
|
||||
} else {
|
||||
print('Warning: Valid Supabase credentials not provided');
|
||||
print('App will run in offline mode');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,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 {
|
||||
|
||||
+37
-28
@@ -1,5 +1,11 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'cached_goal_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class CachedGoalAdapter extends TypeAdapter<CachedGoal> {
|
||||
@override
|
||||
final int typeId = 0;
|
||||
@@ -29,33 +35,34 @@ class CachedGoalAdapter extends TypeAdapter<CachedGoal> {
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, CachedGoal obj) {
|
||||
writer.writeByte(13);
|
||||
writer.writeByte(0);
|
||||
writer.write(obj.id);
|
||||
writer.writeByte(1);
|
||||
writer.write(obj.ownerId);
|
||||
writer.writeByte(2);
|
||||
writer.write(obj.title);
|
||||
writer.writeByte(3);
|
||||
writer.write(obj.description);
|
||||
writer.writeByte(4);
|
||||
writer.write(obj.progress);
|
||||
writer.writeByte(5);
|
||||
writer.write(obj.locationLat);
|
||||
writer.writeByte(6);
|
||||
writer.write(obj.locationLng);
|
||||
writer.writeByte(7);
|
||||
writer.write(obj.locationName);
|
||||
writer.writeByte(8);
|
||||
writer.write(obj.imageUrl);
|
||||
writer.writeByte(9);
|
||||
writer.write(obj.completed);
|
||||
writer.writeByte(10);
|
||||
writer.write(obj.createdAt);
|
||||
writer.writeByte(11);
|
||||
writer.write(obj.updatedAt);
|
||||
writer.writeByte(12);
|
||||
writer.write(obj.isDirty);
|
||||
writer
|
||||
..writeByte(13)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
..write(obj.ownerId)
|
||||
..writeByte(2)
|
||||
..write(obj.title)
|
||||
..writeByte(3)
|
||||
..write(obj.description)
|
||||
..writeByte(4)
|
||||
..write(obj.progress)
|
||||
..writeByte(5)
|
||||
..write(obj.locationLat)
|
||||
..writeByte(6)
|
||||
..write(obj.locationLng)
|
||||
..writeByte(7)
|
||||
..write(obj.locationName)
|
||||
..writeByte(8)
|
||||
..write(obj.imageUrl)
|
||||
..writeByte(9)
|
||||
..write(obj.completed)
|
||||
..writeByte(10)
|
||||
..write(obj.createdAt)
|
||||
..writeByte(11)
|
||||
..write(obj.updatedAt)
|
||||
..writeByte(12)
|
||||
..write(obj.isDirty);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -64,5 +71,7 @@ class CachedGoalAdapter extends TypeAdapter<CachedGoal> {
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is CachedGoalAdapter && runtimeType == other.runtimeType && typeId == other.typeId;
|
||||
other is CachedGoalAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../core/utils/unit_conversion_utils.dart';
|
||||
|
||||
class User extends Equatable {
|
||||
final String id;
|
||||
@@ -13,6 +14,13 @@ class User extends Equatable {
|
||||
final String? websiteUrl;
|
||||
final DateTime? countdownStartDate;
|
||||
final DateTime? countdownEndDate;
|
||||
final Gender? gender;
|
||||
final DateTime? birthDate;
|
||||
final int? storedAge;
|
||||
final double? heightCm;
|
||||
final double? weightKg;
|
||||
final HeightUnit heightUnit;
|
||||
final WeightUnit weightUnit;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
@@ -29,6 +37,13 @@ class User extends Equatable {
|
||||
this.websiteUrl,
|
||||
this.countdownStartDate,
|
||||
this.countdownEndDate,
|
||||
this.gender,
|
||||
this.birthDate,
|
||||
this.storedAge,
|
||||
this.heightCm,
|
||||
this.weightKg,
|
||||
this.heightUnit = HeightUnit.metric,
|
||||
this.weightUnit = WeightUnit.metric,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
@@ -45,6 +60,33 @@ class User extends Equatable {
|
||||
return countdownEndDate!.difference(DateTime.now()).inDays;
|
||||
}
|
||||
|
||||
int? get age {
|
||||
if (storedAge != null) return storedAge;
|
||||
if (birthDate == null) return null;
|
||||
return UnitConversionUtils.calculateAge(birthDate!);
|
||||
}
|
||||
|
||||
String get formattedHeight {
|
||||
if (heightCm == null) return '';
|
||||
return UnitConversionUtils.formatHeight(heightCm!, heightUnit);
|
||||
}
|
||||
|
||||
String get formattedWeight {
|
||||
if (weightKg == null) return '';
|
||||
return UnitConversionUtils.formatWeight(weightKg!, weightUnit);
|
||||
}
|
||||
|
||||
double? get bmi {
|
||||
if (heightCm == null || weightKg == null) return null;
|
||||
return UnitConversionUtils.calculateBmi(weightKg!, heightCm!);
|
||||
}
|
||||
|
||||
String get bmiCategory {
|
||||
final bmiValue = bmi;
|
||||
if (bmiValue == null) return '';
|
||||
return UnitConversionUtils.getBmiCategory(bmiValue);
|
||||
}
|
||||
|
||||
User copyWith({
|
||||
String? id,
|
||||
String? username,
|
||||
@@ -58,6 +100,13 @@ class User extends Equatable {
|
||||
String? websiteUrl,
|
||||
DateTime? countdownStartDate,
|
||||
DateTime? countdownEndDate,
|
||||
Gender? gender,
|
||||
DateTime? birthDate,
|
||||
int? storedAge,
|
||||
double? heightCm,
|
||||
double? weightKg,
|
||||
HeightUnit? heightUnit,
|
||||
WeightUnit? weightUnit,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
@@ -74,6 +123,13 @@ class User extends Equatable {
|
||||
websiteUrl: websiteUrl ?? this.websiteUrl,
|
||||
countdownStartDate: countdownStartDate ?? this.countdownStartDate,
|
||||
countdownEndDate: countdownEndDate ?? this.countdownEndDate,
|
||||
gender: gender ?? this.gender,
|
||||
birthDate: birthDate ?? this.birthDate,
|
||||
storedAge: storedAge ?? this.storedAge,
|
||||
heightCm: heightCm ?? this.heightCm,
|
||||
weightKg: weightKg ?? this.weightKg,
|
||||
heightUnit: heightUnit ?? this.heightUnit,
|
||||
weightUnit: weightUnit ?? this.weightUnit,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
@@ -93,6 +149,13 @@ class User extends Equatable {
|
||||
websiteUrl,
|
||||
countdownStartDate,
|
||||
countdownEndDate,
|
||||
gender,
|
||||
birthDate,
|
||||
storedAge,
|
||||
heightCm,
|
||||
weightKg,
|
||||
heightUnit,
|
||||
weightUnit,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
];
|
||||
@@ -111,6 +174,13 @@ class User extends Equatable {
|
||||
'website_url': websiteUrl,
|
||||
'countdown_start_date': countdownStartDate?.toIso8601String(),
|
||||
'countdown_end_date': countdownEndDate?.toIso8601String(),
|
||||
'gender': gender?.toDatabaseString(),
|
||||
'birth_date': birthDate?.toIso8601String().split('T').first,
|
||||
'age': storedAge,
|
||||
'height_cm': heightCm,
|
||||
'weight_kg': weightKg,
|
||||
'height_unit': heightUnit.code,
|
||||
'weight_unit': weightUnit.code,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'updated_at': updatedAt.toIso8601String(),
|
||||
};
|
||||
@@ -134,6 +204,15 @@ class User extends Equatable {
|
||||
countdownEndDate: json['countdown_end_date'] != null
|
||||
? DateTime.parse(json['countdown_end_date'] as String)
|
||||
: null,
|
||||
gender: json['gender'] != null ? Gender.fromString(json['gender'] as String) : null,
|
||||
birthDate: json['birth_date'] != null ? DateTime.parse(json['birth_date'] as String) : null,
|
||||
storedAge: json['age'] as int?,
|
||||
heightCm: json['height_cm'] as double?,
|
||||
weightKg: json['weight_kg'] as double?,
|
||||
heightUnit: json['height_unit'] != null ?
|
||||
HeightUnit.values.firstWhere((unit) => unit.code == json['height_unit']) : HeightUnit.metric,
|
||||
weightUnit: json['weight_unit'] != null ?
|
||||
WeightUnit.values.firstWhere((unit) => unit.code == json['weight_unit']) : WeightUnit.metric,
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
);
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import 'dart:async';
|
||||
import '../models/user_model.dart';
|
||||
import '../../bootstrap/supabase_client.dart';
|
||||
import '../../core/utils/unit_conversion_utils.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
|
||||
import 'package:google_sign_in/google_sign_in.dart';
|
||||
|
||||
class AuthRepository {
|
||||
final supabase.SupabaseClient _client;
|
||||
final supabase.SupabaseClient? _client;
|
||||
StreamSubscription<supabase.AuthState>? _authStateSubscription;
|
||||
|
||||
AuthRepository([supabase.SupabaseClient? client]) : _client = client ?? supabaseClient;
|
||||
AuthRepository([supabase.SupabaseClient? client]) : _client = client;
|
||||
|
||||
Stream<User?> get authStateChanges {
|
||||
return _client.auth.onAuthStateChange.map((data) {
|
||||
final client = supabaseClient;
|
||||
if (client == null) {
|
||||
// Return a stream that never emits if Supabase is not initialized
|
||||
return Stream.empty();
|
||||
}
|
||||
return client.auth.onAuthStateChange.map((data) {
|
||||
final session = data.session;
|
||||
if (session?.user != null) {
|
||||
return _mapSupabaseUserToAppUser(session!.user);
|
||||
@@ -21,39 +27,53 @@ 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();
|
||||
final expiresAt = session.expiresAt;
|
||||
if (expiresAt == null) return true;
|
||||
|
||||
|
||||
return now.isBefore(DateTime.fromMillisecondsSinceEpoch(expiresAt * 1000));
|
||||
}
|
||||
|
||||
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,22 +88,25 @@ 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 {
|
||||
assert(_client != null, 'Client must not be null');
|
||||
try {
|
||||
final GoogleSignIn googleSignIn = GoogleSignIn(
|
||||
scopes: ['email', 'profile'],
|
||||
@@ -109,6 +132,7 @@ class AuthRepository {
|
||||
}
|
||||
|
||||
Future<void> _handleGoogleUser(dynamic googleUser) async {
|
||||
assert(_client != null, 'Client must not be null');
|
||||
try {
|
||||
final googleAuth = await googleUser.authentication;
|
||||
final idToken = googleAuth.idToken;
|
||||
@@ -118,7 +142,7 @@ class AuthRepository {
|
||||
throw Exception('No ID token or access token from Google sign-in');
|
||||
}
|
||||
|
||||
final response = await _client.auth.signInWithIdToken(
|
||||
final response = await _client!.auth.signInWithIdToken(
|
||||
provider: supabase.OAuthProvider.google,
|
||||
idToken: idToken,
|
||||
accessToken: accessToken,
|
||||
@@ -133,17 +157,20 @@ class AuthRepository {
|
||||
}
|
||||
|
||||
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({
|
||||
@@ -151,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>{};
|
||||
@@ -160,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();
|
||||
@@ -192,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();
|
||||
@@ -206,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),
|
||||
);
|
||||
@@ -217,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)
|
||||
@@ -270,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,88 +6,116 @@ 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 {
|
||||
await Hive.initFlutter();
|
||||
|
||||
if (!Hive.isAdapterRegistered(0)) {
|
||||
Hive.registerAdapter(CachedGoalAdapter());
|
||||
try {
|
||||
await Hive.initFlutter();
|
||||
|
||||
if (!Hive.isAdapterRegistered(0)) {
|
||||
Hive.registerAdapter(CachedGoalAdapter());
|
||||
}
|
||||
|
||||
_goalsBox = await Hive.openBox<CachedGoal>(_goalsBoxName);
|
||||
_userBox = await Hive.openBox(_userBoxName);
|
||||
_countdownBox = await Hive.openBox(_countdownBoxName);
|
||||
} catch (e) {
|
||||
print('Error initializing offline cache: $e');
|
||||
}
|
||||
|
||||
_goalsBox = await Hive.openBox<CachedGoal>(_goalsBoxName);
|
||||
_userBox = await Hive.openBox(_userBoxName);
|
||||
_countdownBox = await Hive.openBox(_countdownBoxName);
|
||||
}
|
||||
|
||||
Future<void> cacheGoals(List<CachedGoal> goals) async {
|
||||
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({
|
||||
@@ -153,6 +153,13 @@ class AIChatController extends StateNotifier<AIChatState> {
|
||||
buffer.writeln(
|
||||
'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);
|
||||
|
||||
@@ -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
|
||||
@@ -87,7 +88,7 @@ class _AIChatScreenState extends ConsumerState<AIChatScreen> {
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha:0.7),
|
||||
.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -128,7 +129,7 @@ class _AIChatScreenState extends ConsumerState<AIChatScreen> {
|
||||
Text(
|
||||
'Ask for goal inspiration, motivation, or life advice',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha:0.7),
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
@@ -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,14 +223,29 @@ class _AIChatScreenState extends ConsumerState<AIChatScreen> {
|
||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
message.content,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: isUser
|
||||
? Theme.of(context).colorScheme.onPrimary
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
child: isUser
|
||||
? Text(
|
||||
message.content,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isUser) ...[
|
||||
@@ -245,6 +265,59 @@ class _AIChatScreenState extends ConsumerState<AIChatScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadingIndicator() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
child: Icon(
|
||||
Icons.psychology,
|
||||
size: 20,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.75,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'AI is thinking...',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorMessage(String error, controller) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
@@ -317,7 +390,7 @@ class _AIChatScreenState extends ConsumerState<AIChatScreen> {
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Theme.of(context).shadowColor.withValues(alpha:0.1),
|
||||
color: Theme.of(context).shadowColor.withValues(alpha: 0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
@@ -362,8 +435,8 @@ class _AIChatScreenState extends ConsumerState<AIChatScreen> {
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: state.isRecording
|
||||
? Theme.of(context).colorScheme.error.withValues(alpha:0.12)
|
||||
: Theme.of(context).colorScheme.primary.withValues(alpha:0.08),
|
||||
? Theme.of(context).colorScheme.error.withValues(alpha: 0.12)
|
||||
: Theme.of(context).colorScheme.primary.withValues(alpha: 0.08),
|
||||
),
|
||||
child: IconButton(
|
||||
onPressed: state.isRecording
|
||||
@@ -429,7 +502,7 @@ class _AIChatScreenState extends ConsumerState<AIChatScreen> {
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha:state.isLoading ||
|
||||
.withValues(alpha: state.isLoading ||
|
||||
_textController.text.trim().isEmpty
|
||||
? 0.06
|
||||
: 0.12),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -234,6 +275,121 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
||||
enabled: !_isLoading,
|
||||
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,
|
||||
|
||||
@@ -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,32 +28,35 @@ class _HomeCountdownScreenState extends ConsumerState<HomeCountdownScreen> {
|
||||
? achievementsState.level
|
||||
: null;
|
||||
|
||||
final child = countdownState.isLoading
|
||||
? const Center(child: LoadingIndicator())
|
||||
: countdownState.error != null
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('Error: ${countdownState.error}'),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () => context.go('/'),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: countdownState.user == null || !countdownState.user!.hasCountdownStarted
|
||||
? _CountdownNotStartedScreen()
|
||||
: _CountdownActiveScreen(
|
||||
user: countdownState.user!,
|
||||
level: level,
|
||||
);
|
||||
|
||||
return AppScaffold(
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 30.0, bottom: 30.0),
|
||||
child: countdownState.isLoading
|
||||
? const Center(child: LoadingIndicator())
|
||||
: countdownState.error != null
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('Error: ${countdownState.error}'),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () => context.go('/'),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: countdownState.user == null || !countdownState.user!.hasCountdownStarted
|
||||
? _CountdownNotStartedScreen()
|
||||
: _CountdownActiveScreen(
|
||||
user: countdownState.user!,
|
||||
level: level,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => context.push('/ai-chat'),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -22,7 +22,7 @@ class OnboardingHowItWorksScreen extends ConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Progress Bar and Navigation
|
||||
_OnboardingProgress(currentStep: 2, totalSteps: 3),
|
||||
const _OnboardingProgress(currentStep: 2, totalSteps: 3),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'How It Works',
|
||||
|
||||
@@ -16,13 +16,14 @@ class OnboardingIntroScreen extends ConsumerWidget {
|
||||
|
||||
return AppScaffold(
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 30.0, left: 24.0, right: 24.0, bottom: 30.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 20.0, left: 24.0, right: 24.0, bottom: 20.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Progress Bar and Navigation
|
||||
_OnboardingProgress(currentStep: 1, totalSteps: 3),
|
||||
const _OnboardingProgress(currentStep: 1, totalSteps: 3),
|
||||
const SizedBox(height: 48),
|
||||
const Icon(
|
||||
Icons.timer_outlined,
|
||||
@@ -64,7 +65,7 @@ class OnboardingIntroScreen extends ConsumerWidget {
|
||||
title: 'Track Progress',
|
||||
description: 'Watch yourself grow day by day',
|
||||
),
|
||||
const Spacer(),
|
||||
const SizedBox(height: 48),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -95,6 +96,7 @@ class OnboardingIntroScreen extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,82 +19,69 @@ class OnboardingMotivationScreen extends ConsumerWidget {
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 30.0, left: 24.0, right: 24.0, bottom: 30.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Progress Bar and Navigation
|
||||
_OnboardingProgress(currentStep: 3, totalSteps: 3),
|
||||
const SizedBox(height: 24),
|
||||
const Icon(
|
||||
Icons.psychology_outlined,
|
||||
size: 80,
|
||||
color: Colors.amber,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Your Time is Now',
|
||||
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Progress Bar and Navigation
|
||||
const _OnboardingProgress(currentStep: 3, totalSteps: 3),
|
||||
const SizedBox(height: 24),
|
||||
const Icon(
|
||||
Icons.psychology_outlined,
|
||||
size: 80,
|
||||
color: Colors.amber,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'1356 days is approximately 3 years and 8 months.\n\n'
|
||||
'That\'s enough time to transform your life, learn new skills, '
|
||||
'build meaningful relationships, and achieve your biggest dreams.\n\n'
|
||||
'Every day counts. Every step matters. Your journey begins now.',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
|
||||
height: 1.5,
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Your Time is Now',
|
||||
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
const _MotivationCard(
|
||||
icon: Icons.trending_up,
|
||||
title: 'Track Progress',
|
||||
description: 'Watch yourself grow as you complete goals and milestones.',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const _MotivationCard(
|
||||
icon: Icons.people,
|
||||
title: 'Join Community',
|
||||
description: 'Connect with others on similar journeys (optional).',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const _MotivationCard(
|
||||
icon: Icons.celebration,
|
||||
title: 'Celebrate Wins',
|
||||
description: 'Every achievement is worth celebrating.',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: () => context.pop(),
|
||||
child: const Text('Back'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'1356 days is approximately 3 years and 8 months.\n\n'
|
||||
'That\'s enough time to transform your life, learn new skills, '
|
||||
'build meaningful relationships, and achieve your biggest dreams.\n\n'
|
||||
'Every day counts. Every step matters. Your journey begins now.',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
|
||||
height: 1.5,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: PrimaryButton(
|
||||
onPressed: () async {
|
||||
controller.completeStep('motivation');
|
||||
await controller.completeOnboarding();
|
||||
if (context.mounted) {
|
||||
context.push('/profile/create');
|
||||
}
|
||||
},
|
||||
text: 'Get Started',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
const _MotivationCard(
|
||||
icon: Icons.trending_up,
|
||||
title: 'Track Progress',
|
||||
description: 'Watch yourself grow as you complete goals and milestones.',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const _MotivationCard(
|
||||
icon: Icons.people,
|
||||
title: 'Join Community',
|
||||
description: 'Connect with others on similar journeys (optional).',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const _MotivationCard(
|
||||
icon: Icons.celebration,
|
||||
title: 'Celebrate Wins',
|
||||
description: 'Every achievement is worth celebrating.',
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
PrimaryButton(
|
||||
onPressed: () async {
|
||||
controller.completeStep('motivation');
|
||||
await controller.completeOnboarding();
|
||||
if (context.mounted) {
|
||||
context.push('/profile/create');
|
||||
}
|
||||
},
|
||||
text: 'Get Started',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
@@ -32,6 +35,11 @@ class _ProfileSetupScreenState extends ConsumerState<ProfileSetupScreen> {
|
||||
bool _isCheckingUsername = false;
|
||||
bool _isUsernameAvailable = true;
|
||||
String? _usernameError;
|
||||
|
||||
Gender? _selectedGender;
|
||||
DateTime? _selectedBirthDate;
|
||||
HeightUnit _selectedHeightUnit = HeightUnit.metric;
|
||||
WeightUnit _selectedWeightUnit = WeightUnit.metric;
|
||||
|
||||
final ImagePicker _imagePicker = ImagePicker();
|
||||
|
||||
@@ -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,116 @@ 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EnhancedStatsSection extends StatelessWidget {
|
||||
final app.User user;
|
||||
final Map<String, dynamic>? userStats;
|
||||
final bool isLoadingStats;
|
||||
|
||||
const _EnhancedStatsSection({
|
||||
required this.user,
|
||||
this.userStats,
|
||||
required this.isLoadingStats,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Profile Stats',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (isLoadingStats)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
else
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _StatCard(
|
||||
icon: Icons.flag,
|
||||
title: 'Goals',
|
||||
value: '${userStats?['goals_count'] ?? 0}',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _StatCard(
|
||||
icon: Icons.check_circle,
|
||||
title: 'Completed',
|
||||
value: '${userStats?['completed_goals_count'] ?? 0}',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _StatCard(
|
||||
icon: Icons.people,
|
||||
title: 'Followers',
|
||||
value: '${userStats?['followers_count'] ?? 0}',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _StatCard(
|
||||
icon: Icons.person_add,
|
||||
title: 'Following',
|
||||
value: '${userStats?['following_count'] ?? 0}',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (user.countdownStartDate != null) ...[
|
||||
_StatCard(
|
||||
icon: Icons.timer,
|
||||
title: 'Challenge Started',
|
||||
value: DateTimeUtils.formatShortDate(user.countdownStartDate!),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (user.daysRemaining != null)
|
||||
_StatCard(
|
||||
icon: Icons.hourglass_empty,
|
||||
title: 'Days Remaining',
|
||||
value: '${user.daysRemaining} days',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
_StatCard(
|
||||
icon: Icons.calendar_today,
|
||||
title: 'Member Since',
|
||||
value: DateTimeUtils.formatShortDate(user.createdAt),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -109,6 +261,217 @@ class _PublicProfileScreenState extends ConsumerState<PublicProfileScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
class _UserGoalsSection extends StatelessWidget {
|
||||
final List<Goal> goals;
|
||||
final bool isLoadingGoals;
|
||||
|
||||
const _UserGoalsSection({
|
||||
required this.goals,
|
||||
required this.isLoadingGoals,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Public Goals',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (isLoadingGoals)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
else
|
||||
...goals.map((goal) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _GoalCard(goal: goal),
|
||||
)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BiometricSection extends StatelessWidget {
|
||||
final app.User user;
|
||||
|
||||
const _BiometricSection({required this.user});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Biometric Information',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
if (user.gender != null)
|
||||
Expanded(
|
||||
child: _BiometricCard(
|
||||
icon: user.gender!.emoji,
|
||||
title: 'Gender',
|
||||
value: user.gender!.displayName,
|
||||
),
|
||||
),
|
||||
if (user.age != null)
|
||||
Expanded(
|
||||
child: _BiometricCard(
|
||||
icon: '🎂',
|
||||
title: 'Age',
|
||||
value: '${user.age} years',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (user.gender != null && user.age != null)
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
if (user.formattedHeight.isNotEmpty)
|
||||
Expanded(
|
||||
child: _BiometricCard(
|
||||
icon: '📏',
|
||||
title: 'Height',
|
||||
value: user.formattedHeight,
|
||||
),
|
||||
),
|
||||
if (user.formattedWeight.isNotEmpty)
|
||||
Expanded(
|
||||
child: _BiometricCard(
|
||||
icon: '⚖️',
|
||||
title: 'Weight',
|
||||
value: user.formattedWeight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (user.bmi != null)
|
||||
Column(
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
_BiometricCard(
|
||||
icon: '💪',
|
||||
title: 'BMI',
|
||||
value: '${user.bmi!.toStringAsFixed(1)} - ${user.bmiCategory}',
|
||||
valueColor: UnitConversionUtils.getBmiColor(user.bmi!),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BiometricCard extends StatelessWidget {
|
||||
final String icon;
|
||||
final String title;
|
||||
final String value;
|
||||
final Color? valueColor;
|
||||
|
||||
const _BiometricCard({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.value,
|
||||
this.valueColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
icon,
|
||||
style: const TextStyle(fontSize: 20),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: valueColor ?? null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GoalCard extends StatelessWidget {
|
||||
final Goal goal;
|
||||
|
||||
const _GoalCard({required this.goal});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: goal.completed
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
goal.completed ? Icons.check : Icons.flag_outlined,
|
||||
color: goal.completed
|
||||
? Theme.of(context).colorScheme.onPrimaryContainer
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
goal.title,
|
||||
style: TextStyle(
|
||||
decoration: goal.completed ? TextDecoration.lineThrough : null,
|
||||
),
|
||||
),
|
||||
subtitle: goal.description != null && goal.description!.isNotEmpty
|
||||
? Text(
|
||||
goal.description!,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: null,
|
||||
trailing: goal.progress > 0
|
||||
? Text('${goal.progress}%')
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProfileHeader extends ConsumerWidget {
|
||||
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;
|
||||
|
||||
@@ -29,13 +29,13 @@ void main() async {
|
||||
|
||||
runApp(
|
||||
const ProviderScope(
|
||||
child: App1356(),
|
||||
child: LifeTimerApp(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class App1356 extends ConsumerWidget {
|
||||
const App1356({super.key});
|
||||
class LifeTimerApp extends ConsumerWidget {
|
||||
const LifeTimerApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
|
||||
Reference in New Issue
Block a user