mirror of
https://github.com/Dvorinka/1356.git
synced 2026-06-05 04:22:55 +00:00
feat: Complete Phase 1 - Full Flutter app implementation with comprehensive features
Version: 1.1.0 Major changes: - Implemented complete Flutter app structure with all core features - Added comprehensive UI screens for auth, countdown, goals, profile, settings, and social features - Integrated Supabase backend with authentication and data repositories - Added offline support with Hive caching and local storage - Implemented comprehensive routing with go_router - Added location services with Google Maps integration - Implemented notifications and home widget support - Added voice recording capabilities and AI chat features - Created comprehensive test suite and documentation - Added Android and iOS platform configurations - Implemented achievements system and social features - Added calendar integration and bucket list functionality This represents a complete Phase 1 milestone with 3,775 additions across 31 files.
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class Achievement extends Equatable {
|
||||
final String id;
|
||||
final String title;
|
||||
final String description;
|
||||
final String icon;
|
||||
final AchievementType type;
|
||||
final int? threshold;
|
||||
final DateTime unlockedAt;
|
||||
final bool isUnlocked;
|
||||
|
||||
const Achievement({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.icon,
|
||||
required this.type,
|
||||
this.threshold,
|
||||
required this.unlockedAt,
|
||||
this.isUnlocked = false,
|
||||
});
|
||||
|
||||
Achievement copyWith({
|
||||
String? id,
|
||||
String? title,
|
||||
String? description,
|
||||
String? icon,
|
||||
AchievementType? type,
|
||||
int? threshold,
|
||||
DateTime? unlockedAt,
|
||||
bool? isUnlocked,
|
||||
}) {
|
||||
return Achievement(
|
||||
id: id ?? this.id,
|
||||
title: title ?? this.title,
|
||||
description: description ?? this.description,
|
||||
icon: icon ?? this.icon,
|
||||
type: type ?? this.type,
|
||||
threshold: threshold ?? this.threshold,
|
||||
unlockedAt: unlockedAt ?? this.unlockedAt,
|
||||
isUnlocked: isUnlocked ?? this.isUnlocked,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
type,
|
||||
threshold,
|
||||
unlockedAt,
|
||||
isUnlocked,
|
||||
];
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'description': description,
|
||||
'icon': icon,
|
||||
'type': type.toString(),
|
||||
'threshold': threshold,
|
||||
'unlocked_at': unlockedAt.toIso8601String(),
|
||||
'is_unlocked': isUnlocked,
|
||||
};
|
||||
}
|
||||
|
||||
factory Achievement.fromJson(Map<String, dynamic> json) {
|
||||
return Achievement(
|
||||
id: json['id'] as String,
|
||||
title: json['title'] as String,
|
||||
description: json['description'] as String,
|
||||
icon: json['icon'] as String,
|
||||
type: AchievementType.values.firstWhere(
|
||||
(e) => e.toString() == json['type'],
|
||||
orElse: () => AchievementType.custom,
|
||||
),
|
||||
threshold: json['threshold'] as int?,
|
||||
unlockedAt: json['unlocked_at'] != null
|
||||
? DateTime.parse(json['unlocked_at'] as String)
|
||||
: DateTime.now(),
|
||||
isUnlocked: json['is_unlocked'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum AchievementType {
|
||||
firstGoal,
|
||||
goalsCompleted5,
|
||||
goalsCompleted10,
|
||||
goalsCompleted20,
|
||||
streak7Days,
|
||||
streak30Days,
|
||||
countdownStarted,
|
||||
countdown25Percent,
|
||||
countdown50Percent,
|
||||
countdown75Percent,
|
||||
countdownCompleted,
|
||||
earlyBird,
|
||||
nightOwl,
|
||||
socialButterfly,
|
||||
custom,
|
||||
}
|
||||
|
||||
extension AchievementTypeExtension on AchievementType {
|
||||
String get displayName {
|
||||
switch (this) {
|
||||
case AchievementType.firstGoal:
|
||||
return 'First Goal';
|
||||
case AchievementType.goalsCompleted5:
|
||||
return '5 Goals';
|
||||
case AchievementType.goalsCompleted10:
|
||||
return '10 Goals';
|
||||
case AchievementType.goalsCompleted20:
|
||||
return '20 Goals';
|
||||
case AchievementType.streak7Days:
|
||||
return '7 Day Streak';
|
||||
case AchievementType.streak30Days:
|
||||
return '30 Day Streak';
|
||||
case AchievementType.countdownStarted:
|
||||
return 'Challenge Started';
|
||||
case AchievementType.countdown25Percent:
|
||||
return '25% Complete';
|
||||
case AchievementType.countdown50Percent:
|
||||
return '50% Complete';
|
||||
case AchievementType.countdown75Percent:
|
||||
return '75% Complete';
|
||||
case AchievementType.countdownCompleted:
|
||||
return 'Challenge Complete';
|
||||
case AchievementType.earlyBird:
|
||||
return 'Early Bird';
|
||||
case AchievementType.nightOwl:
|
||||
return 'Night Owl';
|
||||
case AchievementType.socialButterfly:
|
||||
return 'Social Butterfly';
|
||||
case AchievementType.custom:
|
||||
return 'Custom';
|
||||
}
|
||||
}
|
||||
|
||||
String get iconEmoji {
|
||||
switch (this) {
|
||||
case AchievementType.firstGoal:
|
||||
return '🎯';
|
||||
case AchievementType.goalsCompleted5:
|
||||
return '⭐';
|
||||
case AchievementType.goalsCompleted10:
|
||||
return '🌟';
|
||||
case AchievementType.goalsCompleted20:
|
||||
return '💫';
|
||||
case AchievementType.streak7Days:
|
||||
return '🔥';
|
||||
case AchievementType.streak30Days:
|
||||
return '🏆';
|
||||
case AchievementType.countdownStarted:
|
||||
return '🚀';
|
||||
case AchievementType.countdown25Percent:
|
||||
return '📊';
|
||||
case AchievementType.countdown50Percent:
|
||||
return '📈';
|
||||
case AchievementType.countdown75Percent:
|
||||
return '📉';
|
||||
case AchievementType.countdownCompleted:
|
||||
return '🎉';
|
||||
case AchievementType.earlyBird:
|
||||
return '🌅';
|
||||
case AchievementType.nightOwl:
|
||||
return '🌙';
|
||||
case AchievementType.socialButterfly:
|
||||
return '🦋';
|
||||
case AchievementType.custom:
|
||||
return '🏅';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
part of 'cached_goal_model.dart';
|
||||
|
||||
class CachedGoalAdapter extends TypeAdapter<CachedGoal> {
|
||||
@override
|
||||
final int typeId = 0;
|
||||
|
||||
@override
|
||||
CachedGoal read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return CachedGoal(
|
||||
id: fields[0] as String,
|
||||
ownerId: fields[1] as String,
|
||||
title: fields[2] as String,
|
||||
description: fields[3] as String?,
|
||||
progress: fields[4] as int,
|
||||
locationLat: fields[5] as double?,
|
||||
locationLng: fields[6] as double?,
|
||||
locationName: fields[7] as String?,
|
||||
imageUrl: fields[8] as String?,
|
||||
completed: fields[9] as bool,
|
||||
createdAt: fields[10] as DateTime,
|
||||
updatedAt: fields[11] as DateTime,
|
||||
isDirty: fields[12] as bool,
|
||||
);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is CachedGoalAdapter && runtimeType == other.runtimeType && typeId == other.typeId;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'cached_goal.g.dart';
|
||||
|
||||
@HiveType(typeId: 0)
|
||||
class CachedGoal extends HiveObject {
|
||||
@HiveField(0)
|
||||
final String id;
|
||||
|
||||
@HiveField(1)
|
||||
final String ownerId;
|
||||
|
||||
@HiveField(2)
|
||||
final String title;
|
||||
|
||||
@HiveField(3)
|
||||
final String? description;
|
||||
|
||||
@HiveField(4)
|
||||
final int progress;
|
||||
|
||||
@HiveField(5)
|
||||
final double? locationLat;
|
||||
|
||||
@HiveField(6)
|
||||
final double? locationLng;
|
||||
|
||||
@HiveField(7)
|
||||
final String? locationName;
|
||||
|
||||
@HiveField(8)
|
||||
final String? imageUrl;
|
||||
|
||||
@HiveField(9)
|
||||
final bool completed;
|
||||
|
||||
@HiveField(10)
|
||||
final DateTime createdAt;
|
||||
|
||||
@HiveField(11)
|
||||
final DateTime updatedAt;
|
||||
|
||||
@HiveField(12)
|
||||
final bool isDirty;
|
||||
|
||||
CachedGoal({
|
||||
required this.id,
|
||||
required this.ownerId,
|
||||
required this.title,
|
||||
this.description,
|
||||
required this.progress,
|
||||
this.locationLat,
|
||||
this.locationLng,
|
||||
this.locationName,
|
||||
this.imageUrl,
|
||||
required this.completed,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.isDirty = false,
|
||||
});
|
||||
|
||||
CachedGoal copyWith({
|
||||
String? id,
|
||||
String? ownerId,
|
||||
String? title,
|
||||
String? description,
|
||||
int? progress,
|
||||
double? locationLat,
|
||||
double? locationLng,
|
||||
String? locationName,
|
||||
String? imageUrl,
|
||||
bool? completed,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
bool? isDirty,
|
||||
}) {
|
||||
return CachedGoal(
|
||||
id: id ?? this.id,
|
||||
ownerId: ownerId ?? this.ownerId,
|
||||
title: title ?? this.title,
|
||||
description: description ?? this.description,
|
||||
progress: progress ?? this.progress,
|
||||
locationLat: locationLat ?? this.locationLat,
|
||||
locationLng: locationLng ?? this.locationLng,
|
||||
locationName: locationName ?? this.locationName,
|
||||
imageUrl: imageUrl ?? this.imageUrl,
|
||||
completed: completed ?? this.completed,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
isDirty: isDirty ?? this.isDirty,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'owner_id': ownerId,
|
||||
'title': title,
|
||||
'description': description,
|
||||
'progress': progress,
|
||||
'location_lat': locationLat,
|
||||
'location_lng': locationLng,
|
||||
'location_name': locationName,
|
||||
'image_url': imageUrl,
|
||||
'completed': completed,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'updated_at': updatedAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
factory CachedGoal.fromJson(Map<String, dynamic> json) {
|
||||
return CachedGoal(
|
||||
id: json['id'] as String,
|
||||
ownerId: json['owner_id'] as String,
|
||||
title: json['title'] as String,
|
||||
description: json['description'] as String?,
|
||||
progress: json['progress'] as int,
|
||||
locationLat: json['location_lat'] as double?,
|
||||
locationLng: json['location_lng'] as double?,
|
||||
locationName: json['location_name'] as String?,
|
||||
imageUrl: json['image_url'] as String?,
|
||||
completed: json['completed'] as bool,
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class CalendarEntry extends Equatable {
|
||||
final String id;
|
||||
final String userId;
|
||||
final String? goalId;
|
||||
final DateTime entryDate;
|
||||
final String title;
|
||||
final String? note;
|
||||
final String entryType; // e.g. progress, milestone, reflection
|
||||
final DateTime createdAt;
|
||||
|
||||
const CalendarEntry({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
this.goalId,
|
||||
required this.entryDate,
|
||||
required this.title,
|
||||
this.note,
|
||||
required this.entryType,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
CalendarEntry copyWith({
|
||||
String? id,
|
||||
String? userId,
|
||||
String? goalId,
|
||||
DateTime? entryDate,
|
||||
String? title,
|
||||
String? note,
|
||||
String? entryType,
|
||||
DateTime? createdAt,
|
||||
}) {
|
||||
return CalendarEntry(
|
||||
id: id ?? this.id,
|
||||
userId: userId ?? this.userId,
|
||||
goalId: goalId ?? this.goalId,
|
||||
entryDate: entryDate ?? this.entryDate,
|
||||
title: title ?? this.title,
|
||||
note: note ?? this.note,
|
||||
entryType: entryType ?? this.entryType,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'user_id': userId,
|
||||
'goal_id': goalId,
|
||||
'entry_date': entryDate.toIso8601String().split('T').first,
|
||||
'title': title,
|
||||
'note': note,
|
||||
'entry_type': entryType,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
factory CalendarEntry.fromJson(Map<String, dynamic> json) {
|
||||
return CalendarEntry(
|
||||
id: json['id'] as String,
|
||||
userId: json['user_id'] as String,
|
||||
goalId: json['goal_id'] as String?,
|
||||
entryDate: DateTime.parse(json['entry_date'] as String),
|
||||
title: json['title'] as String,
|
||||
note: json['note'] as String?,
|
||||
entryType: json['entry_type'] as String? ?? 'note',
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
userId,
|
||||
goalId,
|
||||
entryDate,
|
||||
title,
|
||||
note,
|
||||
entryType,
|
||||
createdAt,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
enum MutationType {
|
||||
createGoal,
|
||||
updateGoal,
|
||||
deleteGoal,
|
||||
updateGoalProgress,
|
||||
}
|
||||
|
||||
class OfflineMutation {
|
||||
final String id;
|
||||
final MutationType type;
|
||||
final String? goalId;
|
||||
final Map<String, dynamic>? data;
|
||||
final DateTime createdAt;
|
||||
final DateTime? syncedAt;
|
||||
final bool isSynced;
|
||||
|
||||
OfflineMutation({
|
||||
required this.id,
|
||||
required this.type,
|
||||
this.goalId,
|
||||
this.data,
|
||||
required this.createdAt,
|
||||
this.syncedAt,
|
||||
this.isSynced = false,
|
||||
});
|
||||
|
||||
OfflineMutation copyWith({
|
||||
String? id,
|
||||
MutationType? type,
|
||||
String? goalId,
|
||||
Map<String, dynamic>? data,
|
||||
DateTime? createdAt,
|
||||
DateTime? syncedAt,
|
||||
bool? isSynced,
|
||||
}) {
|
||||
return OfflineMutation(
|
||||
id: id ?? this.id,
|
||||
type: type ?? this.type,
|
||||
goalId: goalId ?? this.goalId,
|
||||
data: data ?? this.data,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
syncedAt: syncedAt ?? this.syncedAt,
|
||||
isSynced: isSynced ?? this.isSynced,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'type': type.name,
|
||||
'goal_id': goalId,
|
||||
'data': data,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'synced_at': syncedAt?.toIso8601String(),
|
||||
'is_synced': isSynced,
|
||||
};
|
||||
}
|
||||
|
||||
factory OfflineMutation.fromJson(Map<String, dynamic> json) {
|
||||
return OfflineMutation(
|
||||
id: json['id'] as String,
|
||||
type: MutationType.values.firstWhere(
|
||||
(e) => e.name == json['type'] as String,
|
||||
),
|
||||
goalId: json['goal_id'] as String?,
|
||||
data: json['data'] as Map<String, dynamic>?,
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
syncedAt: json['synced_at'] != null
|
||||
? DateTime.parse(json['synced_at'] as String)
|
||||
: null,
|
||||
isSynced: json['is_synced'] as bool,
|
||||
);
|
||||
}
|
||||
|
||||
static OfflineMutation createGoalMutation({
|
||||
required String goalId,
|
||||
required Map<String, dynamic> goalData,
|
||||
}) {
|
||||
return OfflineMutation(
|
||||
id: const Uuid().v4(),
|
||||
type: MutationType.createGoal,
|
||||
goalId: goalId,
|
||||
data: goalData,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
static OfflineMutation updateGoalMutation({
|
||||
required String goalId,
|
||||
required Map<String, dynamic> goalData,
|
||||
}) {
|
||||
return OfflineMutation(
|
||||
id: const Uuid().v4(),
|
||||
type: MutationType.updateGoal,
|
||||
goalId: goalId,
|
||||
data: goalData,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
static OfflineMutation deleteGoalMutation({
|
||||
required String goalId,
|
||||
}) {
|
||||
return OfflineMutation(
|
||||
id: const Uuid().v4(),
|
||||
type: MutationType.deleteGoal,
|
||||
goalId: goalId,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
static OfflineMutation updateProgressMutation({
|
||||
required String goalId,
|
||||
required int progress,
|
||||
}) {
|
||||
return OfflineMutation(
|
||||
id: const Uuid().v4(),
|
||||
type: MutationType.updateGoalProgress,
|
||||
goalId: goalId,
|
||||
data: {'progress': progress},
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,10 @@ class User extends Equatable {
|
||||
final String? avatarUrl;
|
||||
final String? bio;
|
||||
final bool isPublicProfile;
|
||||
final String? twitterHandle;
|
||||
final String? instagramHandle;
|
||||
final String? tiktokHandle;
|
||||
final String? websiteUrl;
|
||||
final DateTime? countdownStartDate;
|
||||
final DateTime? countdownEndDate;
|
||||
final DateTime createdAt;
|
||||
@@ -19,6 +23,10 @@ class User extends Equatable {
|
||||
this.avatarUrl,
|
||||
this.bio,
|
||||
this.isPublicProfile = false,
|
||||
this.twitterHandle,
|
||||
this.instagramHandle,
|
||||
this.tiktokHandle,
|
||||
this.websiteUrl,
|
||||
this.countdownStartDate,
|
||||
this.countdownEndDate,
|
||||
required this.createdAt,
|
||||
@@ -44,6 +52,10 @@ class User extends Equatable {
|
||||
String? avatarUrl,
|
||||
String? bio,
|
||||
bool? isPublicProfile,
|
||||
String? twitterHandle,
|
||||
String? instagramHandle,
|
||||
String? tiktokHandle,
|
||||
String? websiteUrl,
|
||||
DateTime? countdownStartDate,
|
||||
DateTime? countdownEndDate,
|
||||
DateTime? createdAt,
|
||||
@@ -56,6 +68,10 @@ class User extends Equatable {
|
||||
avatarUrl: avatarUrl ?? this.avatarUrl,
|
||||
bio: bio ?? this.bio,
|
||||
isPublicProfile: isPublicProfile ?? this.isPublicProfile,
|
||||
twitterHandle: twitterHandle ?? this.twitterHandle,
|
||||
instagramHandle: instagramHandle ?? this.instagramHandle,
|
||||
tiktokHandle: tiktokHandle ?? this.tiktokHandle,
|
||||
websiteUrl: websiteUrl ?? this.websiteUrl,
|
||||
countdownStartDate: countdownStartDate ?? this.countdownStartDate,
|
||||
countdownEndDate: countdownEndDate ?? this.countdownEndDate,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
@@ -71,6 +87,10 @@ class User extends Equatable {
|
||||
avatarUrl,
|
||||
bio,
|
||||
isPublicProfile,
|
||||
twitterHandle,
|
||||
instagramHandle,
|
||||
tiktokHandle,
|
||||
websiteUrl,
|
||||
countdownStartDate,
|
||||
countdownEndDate,
|
||||
createdAt,
|
||||
@@ -85,6 +105,10 @@ class User extends Equatable {
|
||||
'avatar_url': avatarUrl,
|
||||
'bio': bio,
|
||||
'is_public_profile': isPublicProfile,
|
||||
'twitter_handle': twitterHandle,
|
||||
'instagram_handle': instagramHandle,
|
||||
'tiktok_handle': tiktokHandle,
|
||||
'website_url': websiteUrl,
|
||||
'countdown_start_date': countdownStartDate?.toIso8601String(),
|
||||
'countdown_end_date': countdownEndDate?.toIso8601String(),
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
@@ -100,6 +124,10 @@ class User extends Equatable {
|
||||
avatarUrl: json['avatar_url'] as String?,
|
||||
bio: json['bio'] as String?,
|
||||
isPublicProfile: json['is_public_profile'] as bool? ?? false,
|
||||
twitterHandle: json['twitter_handle'] as String?,
|
||||
instagramHandle: json['instagram_handle'] as String?,
|
||||
tiktokHandle: json['tiktok_handle'] as String?,
|
||||
websiteUrl: json['website_url'] as String?,
|
||||
countdownStartDate: json['countdown_start_date'] != null
|
||||
? DateTime.parse(json['countdown_start_date'] as String)
|
||||
: null,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../services/image_cache_service.dart';
|
||||
|
||||
final imageCacheServiceProvider = Provider<ImageCacheService>((ref) {
|
||||
final service = ImageCacheService();
|
||||
ref.onDispose(() => service.dispose());
|
||||
return service;
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../../../bootstrap/env.dart';
|
||||
import '../services/image_search_service.dart';
|
||||
|
||||
final imageSearchServiceProvider = Provider<ImageSearchService>((ref) {
|
||||
return ImageSearchService(
|
||||
accessKey: Env.unsplashAccessKey,
|
||||
client: http.Client(),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../../../bootstrap/env.dart';
|
||||
import '../services/pexels_image_search_service.dart';
|
||||
|
||||
final pexelsImageSearchServiceProvider = Provider<PexelsImageSearchService>((ref) {
|
||||
return PexelsImageSearchService(
|
||||
apiKey: Env.pexelsApiKey,
|
||||
client: http.Client(),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
|
||||
import '../models/achievement_model.dart';
|
||||
import '../../core/errors/failure.dart';
|
||||
|
||||
class AchievementsRepository {
|
||||
final supabase.SupabaseClient _client;
|
||||
|
||||
AchievementsRepository(this._client);
|
||||
|
||||
static final List<Achievement> _availableAchievements = [
|
||||
Achievement(
|
||||
id: 'first_goal',
|
||||
title: 'First Goal',
|
||||
description: 'Complete your first goal',
|
||||
icon: '🎯',
|
||||
type: AchievementType.firstGoal,
|
||||
threshold: 1,
|
||||
unlockedAt: DateTime.now(),
|
||||
),
|
||||
Achievement(
|
||||
id: 'goals_5',
|
||||
title: '5 Goals Completed',
|
||||
description: 'Complete 5 goals',
|
||||
icon: '⭐',
|
||||
type: AchievementType.goalsCompleted5,
|
||||
threshold: 5,
|
||||
unlockedAt: DateTime.now(),
|
||||
),
|
||||
Achievement(
|
||||
id: 'goals_10',
|
||||
title: '10 Goals Completed',
|
||||
description: 'Complete 10 goals',
|
||||
icon: '🌟',
|
||||
type: AchievementType.goalsCompleted10,
|
||||
threshold: 10,
|
||||
unlockedAt: DateTime.now(),
|
||||
),
|
||||
Achievement(
|
||||
id: 'goals_20',
|
||||
title: '20 Goals Completed',
|
||||
description: 'Complete all 20 goals',
|
||||
icon: '💫',
|
||||
type: AchievementType.goalsCompleted20,
|
||||
threshold: 20,
|
||||
unlockedAt: DateTime.now(),
|
||||
),
|
||||
Achievement(
|
||||
id: 'streak_7',
|
||||
title: '7 Day Streak',
|
||||
description: 'Update progress for 7 consecutive days',
|
||||
icon: '🔥',
|
||||
type: AchievementType.streak7Days,
|
||||
threshold: 7,
|
||||
unlockedAt: DateTime.now(),
|
||||
),
|
||||
Achievement(
|
||||
id: 'streak_30',
|
||||
title: '30 Day Streak',
|
||||
description: 'Update progress for 30 consecutive days',
|
||||
icon: '🏆',
|
||||
type: AchievementType.streak30Days,
|
||||
threshold: 30,
|
||||
unlockedAt: DateTime.now(),
|
||||
),
|
||||
Achievement(
|
||||
id: 'countdown_started',
|
||||
title: 'Challenge Started',
|
||||
description: 'Start your 1356-day countdown',
|
||||
icon: '🚀',
|
||||
type: AchievementType.countdownStarted,
|
||||
unlockedAt: DateTime.now(),
|
||||
),
|
||||
Achievement(
|
||||
id: 'countdown_25',
|
||||
title: '25% Complete',
|
||||
description: 'Reach 25% of your countdown',
|
||||
icon: '📊',
|
||||
type: AchievementType.countdown25Percent,
|
||||
unlockedAt: DateTime.now(),
|
||||
),
|
||||
Achievement(
|
||||
id: 'countdown_50',
|
||||
title: '50% Complete',
|
||||
description: 'Reach 50% of your countdown',
|
||||
icon: '📈',
|
||||
type: AchievementType.countdown50Percent,
|
||||
unlockedAt: DateTime.now(),
|
||||
),
|
||||
Achievement(
|
||||
id: 'countdown_75',
|
||||
title: '75% Complete',
|
||||
description: 'Reach 75% of your countdown',
|
||||
icon: '📉',
|
||||
type: AchievementType.countdown75Percent,
|
||||
unlockedAt: DateTime.now(),
|
||||
),
|
||||
Achievement(
|
||||
id: 'countdown_complete',
|
||||
title: 'Challenge Complete',
|
||||
description: 'Complete your 1356-day challenge',
|
||||
icon: '🎉',
|
||||
type: AchievementType.countdownCompleted,
|
||||
unlockedAt: DateTime.now(),
|
||||
),
|
||||
Achievement(
|
||||
id: 'early_bird',
|
||||
title: 'Early Bird',
|
||||
description: 'Update progress before 8 AM',
|
||||
icon: '🌅',
|
||||
type: AchievementType.earlyBird,
|
||||
unlockedAt: DateTime.now(),
|
||||
),
|
||||
Achievement(
|
||||
id: 'night_owl',
|
||||
title: 'Night Owl',
|
||||
description: 'Update progress after 10 PM',
|
||||
icon: '🌙',
|
||||
type: AchievementType.nightOwl,
|
||||
unlockedAt: DateTime.now(),
|
||||
),
|
||||
Achievement(
|
||||
id: 'social_butterfly',
|
||||
title: 'Social Butterfly',
|
||||
description: 'Follow 10 users',
|
||||
icon: '🦋',
|
||||
type: AchievementType.socialButterfly,
|
||||
threshold: 10,
|
||||
unlockedAt: DateTime.now(),
|
||||
),
|
||||
];
|
||||
|
||||
Future<List<Achievement>> getAvailableAchievements() async {
|
||||
return _availableAchievements;
|
||||
}
|
||||
|
||||
Future<List<Achievement>> getUserAchievements(String userId) async {
|
||||
try {
|
||||
final response = await _client
|
||||
.from('user_achievements')
|
||||
.select('*, achievements(*)')
|
||||
.eq('user_id', userId);
|
||||
|
||||
return response.map((json) {
|
||||
final achievementData = json['achievements'] as Map<String, dynamic>;
|
||||
return Achievement.fromJson(achievementData).copyWith(
|
||||
isUnlocked: true,
|
||||
unlockedAt: DateTime.parse(json['unlocked_at'] as String),
|
||||
);
|
||||
}).toList();
|
||||
} catch (e) {
|
||||
if (e is supabase.PostgrestException && e.code == '42P01') {
|
||||
return [];
|
||||
}
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> unlockAchievement(String userId, String achievementId) async {
|
||||
try {
|
||||
await _client.from('user_achievements').insert({
|
||||
'user_id': userId,
|
||||
'achievement_id': achievementId,
|
||||
'unlocked_at': DateTime.now().toIso8601String(),
|
||||
});
|
||||
} catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Achievement?> checkAndUnlockAchievement(
|
||||
String userId,
|
||||
AchievementType type,
|
||||
int currentValue,
|
||||
) async {
|
||||
final achievement = _availableAchievements.firstWhere(
|
||||
(a) => a.type == type,
|
||||
);
|
||||
|
||||
if (achievement.threshold != null && currentValue >= achievement.threshold!) {
|
||||
final userAchievements = await getUserAchievements(userId);
|
||||
final alreadyUnlocked = userAchievements.any((a) => a.type == type);
|
||||
|
||||
if (!alreadyUnlocked) {
|
||||
await unlockAchievement(userId, achievement.id);
|
||||
return achievement.copyWith(isUnlocked: true, unlockedAt: DateTime.now());
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Failure _handleError(dynamic error) {
|
||||
if (error is supabase.PostgrestException) {
|
||||
return ServerFailure(error.message);
|
||||
}
|
||||
return UnknownFailure(error.toString());
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import '../models/user_model.dart';
|
||||
import '../../bootstrap/supabase_client.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart' hide User;
|
||||
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
|
||||
import 'package:google_sign_in/google_sign_in.dart';
|
||||
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
|
||||
|
||||
class AuthRepository {
|
||||
final SupabaseClient _client;
|
||||
final supabase.SupabaseClient _client;
|
||||
StreamSubscription<supabase.AuthState>? _authStateSubscription;
|
||||
|
||||
AuthRepository([SupabaseClient? client]) : _client = client ?? supabaseClient;
|
||||
AuthRepository([supabase.SupabaseClient? client]) : _client = client ?? supabaseClient;
|
||||
|
||||
Stream<User?> get authStateChanges {
|
||||
return _client.auth.onAuthStateChange.map((data) {
|
||||
@@ -22,6 +27,48 @@ class AuthRepository {
|
||||
return user != null ? _mapSupabaseUserToAppUser(user) : null;
|
||||
}
|
||||
|
||||
bool get isAuthenticated => _client.auth.currentUser != null;
|
||||
|
||||
String? get currentUserId => _client.auth.currentUser?.id;
|
||||
|
||||
Future<bool> isSessionValid() async {
|
||||
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 {
|
||||
try {
|
||||
await _client.auth.refreshSession();
|
||||
} catch (e) {
|
||||
throw Exception('Failed to refresh session: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<supabase.Session?> getCurrentSession() async {
|
||||
return _client.auth.currentSession;
|
||||
}
|
||||
|
||||
void listenToAuthStateChanges(Function(User?) callback) {
|
||||
_authStateSubscription = _client.auth.onAuthStateChange.listen((data) {
|
||||
final session = data.session;
|
||||
if (session?.user != null) {
|
||||
callback(_mapSupabaseUserToAppUser(session!.user));
|
||||
} else {
|
||||
callback(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_authStateSubscription?.cancel();
|
||||
}
|
||||
|
||||
Future<void> signInWithEmail(String email, String password) async {
|
||||
await _client.auth.signInWithPassword(email: email, password: password);
|
||||
}
|
||||
@@ -39,15 +86,58 @@ class AuthRepository {
|
||||
}
|
||||
|
||||
Future<void> signInWithGoogle() async {
|
||||
// TODO: Implement Google OAuth
|
||||
// await _client.auth.signInWithOAuth(OAuthProvider.google);
|
||||
throw UnimplementedError('Google OAuth not implemented yet');
|
||||
final GoogleSignIn googleSignIn = GoogleSignIn();
|
||||
|
||||
final googleUser = await googleSignIn.signIn();
|
||||
if (googleUser == null) {
|
||||
throw Exception('Google sign-in was cancelled');
|
||||
}
|
||||
|
||||
final googleAuth = await googleUser.authentication;
|
||||
final idToken = googleAuth.idToken;
|
||||
|
||||
if (idToken == null) {
|
||||
throw Exception('No ID token from Google sign-in');
|
||||
}
|
||||
|
||||
final response = await _client.auth.signInWithIdToken(
|
||||
provider: supabase.OAuthProvider.google,
|
||||
idToken: idToken,
|
||||
);
|
||||
|
||||
if (response.user != null) {
|
||||
await _ensureUserProfileExists(response.user!.id, response.user!);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> signInWithApple() async {
|
||||
// TODO: Implement Apple OAuth
|
||||
// await _client.auth.signInWithOAuth(OAuthProvider.apple);
|
||||
throw UnimplementedError('Apple OAuth not implemented yet');
|
||||
final credential = await SignInWithApple.getAppleIDCredential(
|
||||
scopes: [
|
||||
AppleIDAuthorizationScopes.email,
|
||||
AppleIDAuthorizationScopes.fullName,
|
||||
],
|
||||
);
|
||||
|
||||
final identityToken = credential.identityToken;
|
||||
if (identityToken == null) {
|
||||
throw Exception('No identity token from Apple sign-in');
|
||||
}
|
||||
|
||||
final response = await _client.auth.signInWithIdToken(
|
||||
provider: supabase.OAuthProvider.apple,
|
||||
idToken: identityToken,
|
||||
accessToken: credential.authorizationCode,
|
||||
);
|
||||
|
||||
if (response.user != null) {
|
||||
await _ensureUserProfileExists(response.user!.id, response.user!);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> signInWithGithub() async {
|
||||
await _client.auth.signInWithOAuth(
|
||||
supabase.OAuthProvider.github,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> signOut() async {
|
||||
@@ -82,7 +172,7 @@ class AuthRepository {
|
||||
|
||||
Future<User> _createUserProfile(String userId, String username, String email) async {
|
||||
final now = DateTime.now().toIso8601String();
|
||||
|
||||
|
||||
final response = await _client.from('users').insert({
|
||||
'id': userId,
|
||||
'username': username,
|
||||
@@ -94,6 +184,21 @@ class AuthRepository {
|
||||
return _mapSupabaseDataToUser(response);
|
||||
}
|
||||
|
||||
Future<void> _ensureUserProfileExists(String userId, dynamic supabaseUser) async {
|
||||
final existingProfile = await _client
|
||||
.from('users')
|
||||
.select('id')
|
||||
.eq('id', userId)
|
||||
.maybeSingle();
|
||||
|
||||
if (existingProfile == null) {
|
||||
final username = supabaseUser.userMetadata?['username'] ??
|
||||
'user_${userId.substring(0, 8)}';
|
||||
final email = supabaseUser.email ?? '';
|
||||
await _createUserProfile(userId, username, email);
|
||||
}
|
||||
}
|
||||
|
||||
User _mapSupabaseUserToAppUser(dynamic supabaseUser) {
|
||||
return User(
|
||||
id: supabaseUser.id,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
|
||||
|
||||
import '../models/calendar_entry_model.dart';
|
||||
import '../../core/errors/failure.dart';
|
||||
|
||||
class CalendarRepository {
|
||||
final supabase.SupabaseClient _client;
|
||||
|
||||
CalendarRepository(this._client);
|
||||
|
||||
Future<List<CalendarEntry>> getEntriesForDate({
|
||||
required String userId,
|
||||
required DateTime date,
|
||||
}) async {
|
||||
try {
|
||||
final dateStr = date.toIso8601String().split('T').first;
|
||||
|
||||
final response = await _client
|
||||
.from('calendar_entries')
|
||||
.select()
|
||||
.eq('user_id', userId)
|
||||
.eq('entry_date', dateStr)
|
||||
.order('created_at', ascending: true);
|
||||
|
||||
return (response as List)
|
||||
.map((json) => CalendarEntry.fromJson(json))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<CalendarEntry> addEntry({
|
||||
required String userId,
|
||||
required DateTime date,
|
||||
required String title,
|
||||
String? note,
|
||||
String entryType = 'note',
|
||||
String? goalId,
|
||||
}) async {
|
||||
try {
|
||||
final dateStr = date.toIso8601String().split('T').first;
|
||||
|
||||
final response = await _client
|
||||
.from('calendar_entries')
|
||||
.insert({
|
||||
'user_id': userId,
|
||||
'goal_id': goalId,
|
||||
'entry_date': dateStr,
|
||||
'title': title,
|
||||
'note': note,
|
||||
'entry_type': entryType,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
return CalendarEntry.fromJson(response);
|
||||
} catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Failure _handleError(dynamic error) {
|
||||
if (error is supabase.PostgrestException) {
|
||||
return ServerFailure(error.message);
|
||||
}
|
||||
return UnknownFailure(error.toString());
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,11 @@ class CountdownRepository {
|
||||
|
||||
Future<app.User> startCountdown(String userId) async {
|
||||
try {
|
||||
final user = await getCountdownInfo(userId);
|
||||
if (user.countdownStartDate != null) {
|
||||
throw const ValidationFailure('Countdown has already started and cannot be restarted');
|
||||
}
|
||||
|
||||
final startDate = DateTime.now();
|
||||
final endDate = DateTimeUtils.calculateEndDate(startDate);
|
||||
|
||||
|
||||
@@ -69,6 +69,21 @@ class GoalsRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> canModifyGoals(String userId) async {
|
||||
try {
|
||||
final response = await _client
|
||||
.from('users')
|
||||
.select('countdown_start_date')
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
|
||||
final countdownStartDate = response['countdown_start_date'];
|
||||
return countdownStartDate == null;
|
||||
} catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteGoal(String goalId) async {
|
||||
try {
|
||||
await _client.from('goals').delete().eq('id', goalId);
|
||||
|
||||
@@ -27,6 +27,10 @@ class UserRepository {
|
||||
String? avatarUrl,
|
||||
String? bio,
|
||||
bool? isPublicProfile,
|
||||
String? twitterHandle,
|
||||
String? instagramHandle,
|
||||
String? tiktokHandle,
|
||||
String? websiteUrl,
|
||||
}) async {
|
||||
try {
|
||||
final updates = <String, dynamic>{};
|
||||
@@ -34,6 +38,10 @@ class UserRepository {
|
||||
if (avatarUrl != null) updates['avatar_url'] = avatarUrl;
|
||||
if (bio != null) updates['bio'] = bio;
|
||||
if (isPublicProfile != null) updates['is_public_profile'] = isPublicProfile;
|
||||
if (twitterHandle != null) updates['twitter_handle'] = twitterHandle;
|
||||
if (instagramHandle != null) updates['instagram_handle'] = instagramHandle;
|
||||
if (tiktokHandle != null) updates['tiktok_handle'] = tiktokHandle;
|
||||
if (websiteUrl != null) updates['website_url'] = websiteUrl;
|
||||
updates['updated_at'] = DateTime.now().toIso8601String();
|
||||
|
||||
final response = await _client
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:home_widget/home_widget.dart';
|
||||
|
||||
class HomeScreenWidgetService {
|
||||
static const androidWidgetProvider = 'NextCountdownWidgetProvider';
|
||||
|
||||
Future<void> updateNextCountdownWidget({
|
||||
required String title,
|
||||
required String timeLeft,
|
||||
String? subtitle,
|
||||
}) async {
|
||||
await HomeWidget.saveWidgetData<String>('next_title', title);
|
||||
await HomeWidget.saveWidgetData<String>('next_time_left', timeLeft);
|
||||
if (subtitle != null) {
|
||||
await HomeWidget.saveWidgetData<String>('next_subtitle', subtitle);
|
||||
}
|
||||
|
||||
await HomeWidget.updateWidget(name: androidWidgetProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// ignore_for_file: depend_on_referenced_packages
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'dart:convert';
|
||||
|
||||
class ImageCacheService {
|
||||
static const int _maxCacheSize = 50 * 1024 * 1024; // 50MB
|
||||
static const Duration _cacheExpiry = Duration(days: 30);
|
||||
static const int _maxConcurrentOperations = 3;
|
||||
|
||||
late Directory _cacheDir;
|
||||
bool _initialized = false;
|
||||
int _activeOperations = 0;
|
||||
|
||||
Future<void> init() async {
|
||||
if (_initialized) return;
|
||||
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
_cacheDir = Directory(path.join(appDir.path, 'image_cache'));
|
||||
|
||||
if (!await _cacheDir.exists()) {
|
||||
await _cacheDir.create(recursive: true);
|
||||
}
|
||||
|
||||
_initialized = true;
|
||||
await _cleanupExpiredCache();
|
||||
}
|
||||
|
||||
String _generateCacheKey(String url) {
|
||||
final bytes = utf8.encode(url);
|
||||
final digest = sha256.convert(bytes);
|
||||
return digest.toString();
|
||||
}
|
||||
|
||||
Future<File?> getCachedImage(String url) async {
|
||||
if (!_initialized) await init();
|
||||
|
||||
final cacheKey = _generateCacheKey(url);
|
||||
final cachedFile = File(path.join(_cacheDir.path, '$cacheKey.jpg'));
|
||||
|
||||
if (!await cachedFile.exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final stat = await cachedFile.stat();
|
||||
final age = DateTime.now().difference(stat.modified);
|
||||
|
||||
if (age > _cacheExpiry) {
|
||||
await cachedFile.delete();
|
||||
return null;
|
||||
}
|
||||
|
||||
return cachedFile;
|
||||
}
|
||||
|
||||
Future<File> cacheImage(String url, Uint8List imageData) async {
|
||||
if (!_initialized) await init();
|
||||
|
||||
// Limit concurrent operations to avoid overwhelming the system
|
||||
while (_activeOperations >= _maxConcurrentOperations) {
|
||||
await Future.delayed(const Duration(milliseconds: 10));
|
||||
}
|
||||
|
||||
_activeOperations++;
|
||||
|
||||
try {
|
||||
final cacheKey = _generateCacheKey(url);
|
||||
final cachedFile = File(path.join(_cacheDir.path, '$cacheKey.jpg'));
|
||||
|
||||
await cachedFile.writeAsBytes(imageData);
|
||||
|
||||
await _enforceCacheSizeLimit();
|
||||
|
||||
return cachedFile;
|
||||
} finally {
|
||||
_activeOperations--;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearCache() async {
|
||||
if (!_initialized) await init();
|
||||
|
||||
if (await _cacheDir.exists()) {
|
||||
await _cacheDir.delete(recursive: true);
|
||||
await _cacheDir.create(recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> getCacheSize() async {
|
||||
if (!_initialized) await init();
|
||||
|
||||
int totalSize = 0;
|
||||
|
||||
if (await _cacheDir.exists()) {
|
||||
await for (final entity in _cacheDir.list()) {
|
||||
if (entity is File) {
|
||||
totalSize += await entity.length();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return totalSize;
|
||||
}
|
||||
|
||||
Future<void> _cleanupExpiredCache() async {
|
||||
if (!await _cacheDir.exists()) return;
|
||||
|
||||
final now = DateTime.now();
|
||||
|
||||
await for (final entity in _cacheDir.list()) {
|
||||
if (entity is File) {
|
||||
final stat = await entity.stat();
|
||||
final age = now.difference(stat.modified);
|
||||
|
||||
if (age > _cacheExpiry) {
|
||||
await entity.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _enforceCacheSizeLimit() async {
|
||||
final currentSize = await getCacheSize();
|
||||
|
||||
if (currentSize <= _maxCacheSize) return;
|
||||
|
||||
final files = <File>[];
|
||||
final fileStats = <File, FileStat>{};
|
||||
|
||||
await for (final entity in _cacheDir.list()) {
|
||||
if (entity is File) {
|
||||
files.add(entity);
|
||||
fileStats[entity] = await entity.stat();
|
||||
}
|
||||
}
|
||||
|
||||
files.sort((a, b) {
|
||||
final statA = fileStats[a]!;
|
||||
final statB = fileStats[b]!;
|
||||
return statA.modified.compareTo(statB.modified);
|
||||
});
|
||||
|
||||
int sizeToRemove = currentSize - _maxCacheSize;
|
||||
|
||||
for (final file in files) {
|
||||
if (sizeToRemove <= 0) break;
|
||||
|
||||
final fileSize = await file.length();
|
||||
await file.delete();
|
||||
sizeToRemove -= fileSize;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
_initialized = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class UnsplashImage {
|
||||
final String id;
|
||||
final String url;
|
||||
final String fullUrl;
|
||||
final String? description;
|
||||
final String? photographer;
|
||||
final String? photographerUrl;
|
||||
|
||||
UnsplashImage({
|
||||
required this.id,
|
||||
required this.url,
|
||||
required this.fullUrl,
|
||||
this.description,
|
||||
this.photographer,
|
||||
this.photographerUrl,
|
||||
});
|
||||
|
||||
factory UnsplashImage.fromJson(Map<String, dynamic> json) {
|
||||
final urls = json['urls'] as Map<String, dynamic>;
|
||||
final user = json['user'] as Map<String, dynamic>?;
|
||||
return UnsplashImage(
|
||||
id: json['id'] as String,
|
||||
url: urls['regular'] as String? ?? urls['small'] as String,
|
||||
fullUrl: urls['full'] as String? ?? urls['regular'] as String,
|
||||
description: json['description'] as String?,
|
||||
photographer: user?['name'] as String?,
|
||||
photographerUrl: user?['links']?['html'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ImageSearchService {
|
||||
final String _accessKey;
|
||||
final http.Client _client;
|
||||
|
||||
ImageSearchService({
|
||||
required String accessKey,
|
||||
http.Client? client,
|
||||
}) : _accessKey = accessKey,
|
||||
_client = client ?? http.Client();
|
||||
|
||||
Future<List<UnsplashImage>> searchImages({
|
||||
required String query,
|
||||
int perPage = 10,
|
||||
String orientation = 'landscape',
|
||||
}) async {
|
||||
try {
|
||||
final uri = Uri.https('api.unsplash.com', '/search/photos', {
|
||||
'query': query,
|
||||
'per_page': perPage.toString(),
|
||||
'orientation': orientation,
|
||||
});
|
||||
|
||||
final response = await _client.get(
|
||||
uri,
|
||||
headers: {
|
||||
'Authorization': 'Client-ID $_accessKey',
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body) as Map<String, dynamic>;
|
||||
final results = data['results'] as List;
|
||||
return results
|
||||
.map((json) => UnsplashImage.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
} else {
|
||||
throw Exception('Failed to search images: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Error searching images: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<UnsplashImage?> getRandomImage({
|
||||
String? query,
|
||||
String orientation = 'landscape',
|
||||
}) async {
|
||||
try {
|
||||
final params = <String, String>{
|
||||
'orientation': orientation,
|
||||
};
|
||||
if (query != null) {
|
||||
params['query'] = query;
|
||||
}
|
||||
|
||||
final uri = Uri.https('api.unsplash.com', '/photos/random', params);
|
||||
|
||||
final response = await _client.get(
|
||||
uri,
|
||||
headers: {
|
||||
'Authorization': 'Client-ID $_accessKey',
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return UnsplashImage.fromJson(json);
|
||||
} else {
|
||||
throw Exception('Failed to get random image: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Error getting random image: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_client.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../../bootstrap/env.dart';
|
||||
|
||||
class ChatMessage {
|
||||
final String content;
|
||||
final String role;
|
||||
final DateTime timestamp;
|
||||
|
||||
ChatMessage({
|
||||
required this.content,
|
||||
required this.role,
|
||||
DateTime? timestamp,
|
||||
}) : timestamp = timestamp ?? DateTime.now();
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'content': content,
|
||||
'role': role,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
factory ChatMessage.fromJson(Map<String, dynamic> json) {
|
||||
return ChatMessage(
|
||||
content: json['content'] as String,
|
||||
role: json['role'] as String,
|
||||
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MistralAIException implements Exception {
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
|
||||
MistralAIException(this.message, [this.statusCode]);
|
||||
|
||||
@override
|
||||
String toString() => 'MistralAIException: $message${statusCode != null ? ' (Status: $statusCode)' : ''}';
|
||||
}
|
||||
|
||||
class MistralAIService {
|
||||
final String _apiKey;
|
||||
final http.Client _client;
|
||||
|
||||
MistralAIService({
|
||||
required String apiKey,
|
||||
http.Client? client,
|
||||
}) : _apiKey = apiKey,
|
||||
_client = client ?? http.Client();
|
||||
|
||||
Future<String> chat({
|
||||
required String message,
|
||||
String model = Env.mistralChatModel,
|
||||
List<ChatMessage>? conversationHistory,
|
||||
String? userContext,
|
||||
}) async {
|
||||
try {
|
||||
final messages = <Map<String, String>>[];
|
||||
|
||||
// Add system prompt for LifeTimer context
|
||||
messages.add({
|
||||
'role': 'system',
|
||||
'content': '''You are an AI assistant for LifeTimer, a gamified life countdown app where users create a bucket list and start a 1356-day countdown.
|
||||
Your role is to help users with:
|
||||
1. Goal setting and bucket list inspiration
|
||||
2. Motivation and encouragement
|
||||
3. Life advice and productivity tips
|
||||
4. Creative ideas for experiences
|
||||
Be inspiring, practical, and encouraging. Keep responses concise but meaningful.
|
||||
If user context is provided, use it to personalise your responses while respecting any stated privacy limitations.''',
|
||||
});
|
||||
|
||||
// Add optional structured user context as a separate system message
|
||||
if (userContext != null && userContext.trim().isNotEmpty) {
|
||||
messages.add({
|
||||
'role': 'system',
|
||||
'content': 'Current user context for this conversation: ${userContext.trim()}',
|
||||
});
|
||||
}
|
||||
|
||||
// Add conversation history if provided
|
||||
if (conversationHistory != null) {
|
||||
final recentMessages = conversationHistory.length > 10
|
||||
? conversationHistory.sublist(conversationHistory.length - 10)
|
||||
: conversationHistory;
|
||||
for (final msg in recentMessages) { // Keep last 10 messages for context
|
||||
messages.add({
|
||||
'role': msg.role,
|
||||
'content': msg.content,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add current message
|
||||
messages.add({
|
||||
'role': 'user',
|
||||
'content': message,
|
||||
});
|
||||
|
||||
final uri = Uri.https('api.mistral.ai', '/v1/chat/completions');
|
||||
|
||||
final response = await _client.post(
|
||||
uri,
|
||||
headers: {
|
||||
'Authorization': 'Bearer $_apiKey',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: jsonEncode({
|
||||
'model': model,
|
||||
'messages': messages,
|
||||
'max_tokens': 500,
|
||||
'temperature': 0.7,
|
||||
}),
|
||||
);
|
||||
|
||||
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;
|
||||
} else {
|
||||
throw MistralAIException(
|
||||
'Failed to get chat response',
|
||||
response.statusCode,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e is MistralAIException) rethrow;
|
||||
throw MistralAIException('Error in chat: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> transcribeAudio({
|
||||
required String audioFilePath,
|
||||
String model = Env.mistralVoiceModel,
|
||||
}) async {
|
||||
try {
|
||||
final uri = Uri.https('api.mistral.ai', '/v1/audio/transcriptions');
|
||||
|
||||
final request = http.MultipartRequest('POST', uri)
|
||||
..headers['Authorization'] = 'Bearer $_apiKey'
|
||||
..fields['model'] = model
|
||||
..files.add(await http.MultipartFile.fromPath('file', audioFilePath));
|
||||
|
||||
final response = await request.send();
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final responseBody = await response.stream.bytesToString();
|
||||
final data = jsonDecode(responseBody) as Map<String, dynamic>;
|
||||
return data['text'] as String;
|
||||
} else {
|
||||
throw MistralAIException(
|
||||
'Failed to transcribe audio',
|
||||
response.statusCode,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e is MistralAIException) rethrow;
|
||||
throw MistralAIException('Error in transcription: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_client.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import '../models/cached_goal_model.dart';
|
||||
|
||||
class OfflineCacheService {
|
||||
static const String _goalsBoxName = 'cached_goals';
|
||||
static const String _userBoxName = 'cached_user';
|
||||
static const String _countdownBoxName = 'cached_countdown';
|
||||
|
||||
late Box<CachedGoal> _goalsBox;
|
||||
late Box _userBox;
|
||||
late Box _countdownBox;
|
||||
|
||||
Future<void> init() async {
|
||||
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);
|
||||
}
|
||||
|
||||
Future<void> cacheGoals(List<CachedGoal> goals) async {
|
||||
await _goalsBox.clear();
|
||||
for (var goal in goals) {
|
||||
await _goalsBox.put(goal.id, goal);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<CachedGoal>> getCachedGoals() async {
|
||||
return _goalsBox.values.toList();
|
||||
}
|
||||
|
||||
Future<CachedGoal?> getCachedGoal(String goalId) async {
|
||||
return _goalsBox.get(goalId);
|
||||
}
|
||||
|
||||
Future<void> cacheGoal(CachedGoal goal) async {
|
||||
await _goalsBox.put(goal.id, goal);
|
||||
}
|
||||
|
||||
Future<void> deleteCachedGoal(String goalId) async {
|
||||
await _goalsBox.delete(goalId);
|
||||
}
|
||||
|
||||
Future<void> markGoalAsDirty(String goalId) async {
|
||||
final goal = _goalsBox.get(goalId);
|
||||
if (goal != null) {
|
||||
await _goalsBox.put(goalId, goal.copyWith(isDirty: true));
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<CachedGoal>> getDirtyGoals() async {
|
||||
return _goalsBox.values.where((goal) => goal.isDirty).toList();
|
||||
}
|
||||
|
||||
Future<void> clearDirtyFlag(String goalId) async {
|
||||
final goal = _goalsBox.get(goalId);
|
||||
if (goal != null) {
|
||||
await _goalsBox.put(goalId, goal.copyWith(isDirty: false));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cacheUserData(Map<String, dynamic> userData) async {
|
||||
await _userBox.putAll(userData);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getCachedUserData() async {
|
||||
return Map<String, dynamic>.from(_userBox.toMap());
|
||||
}
|
||||
|
||||
Future<void> cacheCountdownData(Map<String, dynamic> countdownData) async {
|
||||
await _countdownBox.putAll(countdownData);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getCachedCountdownData() async {
|
||||
return Map<String, dynamic>.from(_countdownBox.toMap());
|
||||
}
|
||||
|
||||
Future<void> clearAllCache() async {
|
||||
await _goalsBox.clear();
|
||||
await _userBox.clear();
|
||||
await _countdownBox.clear();
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
await _goalsBox.close();
|
||||
await _userBox.close();
|
||||
await _countdownBox.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
import 'package:hive/hive.dart';
|
||||
import '../models/offline_mutation_model.dart';
|
||||
|
||||
class OfflineMutationQueue {
|
||||
static const String _mutationsBoxName = 'offline_mutations';
|
||||
late Box<OfflineMutation> _mutationsBox;
|
||||
|
||||
Future<void> init() async {
|
||||
_mutationsBox = await Hive.openBox<OfflineMutation>(_mutationsBoxName);
|
||||
}
|
||||
|
||||
Future<void> enqueueMutation(OfflineMutation mutation) async {
|
||||
await _mutationsBox.put(mutation.id, mutation);
|
||||
}
|
||||
|
||||
Future<List<OfflineMutation>> getPendingMutations() async {
|
||||
return _mutationsBox.values
|
||||
.where((mutation) => !mutation.isSynced)
|
||||
.toList()
|
||||
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||
}
|
||||
|
||||
Future<void> markMutationAsSynced(String mutationId) async {
|
||||
final mutation = _mutationsBox.get(mutationId);
|
||||
if (mutation != null) {
|
||||
await _mutationsBox.put(
|
||||
mutationId,
|
||||
mutation.copyWith(
|
||||
isSynced: true,
|
||||
syncedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> removeSyncedMutations() async {
|
||||
final syncedMutations = _mutationsBox.values
|
||||
.where((mutation) => mutation.isSynced)
|
||||
.toList();
|
||||
|
||||
for (var mutation in syncedMutations) {
|
||||
await _mutationsBox.delete(mutation.id);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearMutation(String mutationId) async {
|
||||
await _mutationsBox.delete(mutationId);
|
||||
}
|
||||
|
||||
Future<void> clearAllMutations() async {
|
||||
await _mutationsBox.clear();
|
||||
}
|
||||
|
||||
Future<int> getPendingMutationCount() async {
|
||||
return _mutationsBox.values.where((m) => !m.isSynced).length;
|
||||
}
|
||||
|
||||
Future<void> syncPendingMutations({
|
||||
required Future<void> Function(OfflineMutation) onSync,
|
||||
}) async {
|
||||
final pendingMutations = await getPendingMutations();
|
||||
|
||||
for (var mutation in pendingMutations) {
|
||||
try {
|
||||
await onSync(mutation);
|
||||
await markMutationAsSynced(mutation.id);
|
||||
} catch (e, stackTrace) {
|
||||
// Log error but continue with next mutation
|
||||
developer.log(
|
||||
'Error syncing mutation ${mutation.id}: $e',
|
||||
name: 'OfflineMutationQueue',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await removeSyncedMutations();
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
await _mutationsBox.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class PexelsImage {
|
||||
final int id;
|
||||
final String url;
|
||||
final String fullUrl;
|
||||
final String? photographer;
|
||||
final String? photographerUrl;
|
||||
final int? width;
|
||||
final int? height;
|
||||
final String? alt;
|
||||
|
||||
PexelsImage({
|
||||
required this.id,
|
||||
required this.url,
|
||||
required this.fullUrl,
|
||||
this.photographer,
|
||||
this.photographerUrl,
|
||||
this.width,
|
||||
this.height,
|
||||
this.alt,
|
||||
});
|
||||
|
||||
factory PexelsImage.fromJson(Map<String, dynamic> json) {
|
||||
final src = json['src'] as Map<String, dynamic>;
|
||||
return PexelsImage(
|
||||
id: json['id'] as int,
|
||||
url: src['large'] as String? ?? src['medium'] as String,
|
||||
fullUrl: src['original'] as String? ?? src['large'] as String,
|
||||
photographer: json['photographer'] as String?,
|
||||
photographerUrl: json['photographer_url'] as String?,
|
||||
width: json['width'] as int?,
|
||||
height: json['height'] as int?,
|
||||
alt: json['alt'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PexelsImageSearchService {
|
||||
final String _apiKey;
|
||||
final http.Client _client;
|
||||
|
||||
PexelsImageSearchService({
|
||||
required String apiKey,
|
||||
http.Client? client,
|
||||
}) : _apiKey = apiKey,
|
||||
_client = client ?? http.Client();
|
||||
|
||||
Future<List<PexelsImage>> searchImages({
|
||||
required String query,
|
||||
int perPage = 10,
|
||||
String orientation = 'landscape',
|
||||
}) async {
|
||||
try {
|
||||
final uri = Uri.https('api.pexels.com', '/v1/search', {
|
||||
'query': query,
|
||||
'per_page': perPage.toString(),
|
||||
'orientation': orientation,
|
||||
});
|
||||
|
||||
final response = await _client.get(
|
||||
uri,
|
||||
headers: {
|
||||
'Authorization': _apiKey,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body) as Map<String, dynamic>;
|
||||
final photos = data['photos'] as List;
|
||||
return photos
|
||||
.map((json) => PexelsImage.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
} else {
|
||||
throw Exception('Failed to search images: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Error searching images: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<PexelsImage?> getRandomImage({
|
||||
String? query,
|
||||
String orientation = 'landscape',
|
||||
}) async {
|
||||
try {
|
||||
final params = <String, String>{
|
||||
'per_page': '1',
|
||||
'orientation': orientation,
|
||||
};
|
||||
if (query != null) {
|
||||
params['query'] = query;
|
||||
}
|
||||
|
||||
final uri = Uri.https('api.pexels.com', '/v1/curated', params);
|
||||
|
||||
final response = await _client.get(
|
||||
uri,
|
||||
headers: {
|
||||
'Authorization': _apiKey,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body) as Map<String, dynamic>;
|
||||
final photos = data['photos'] as List;
|
||||
if (photos.isNotEmpty) {
|
||||
return PexelsImage.fromJson(photos[0] as Map<String, dynamic>);
|
||||
}
|
||||
return null;
|
||||
} else {
|
||||
throw Exception('Failed to get random image: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Error getting random image: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_client.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import 'dart:io';
|
||||
import 'package:record/record.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'mistral_ai_service.dart';
|
||||
|
||||
class VoiceRecordingException implements Exception {
|
||||
final String message;
|
||||
|
||||
VoiceRecordingException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => 'VoiceRecordingException: $message';
|
||||
}
|
||||
|
||||
class VoiceRecordingService {
|
||||
final AudioRecorder _recorder = AudioRecorder();
|
||||
final MistralAIService _mistralService;
|
||||
String? _currentRecordingPath;
|
||||
bool _isRecording = false;
|
||||
|
||||
VoiceRecordingService({required MistralAIService mistralService})
|
||||
: _mistralService = mistralService;
|
||||
|
||||
bool get isRecording => _isRecording;
|
||||
|
||||
Future<bool> requestPermissions() async {
|
||||
try {
|
||||
final microphoneStatus = await Permission.microphone.request();
|
||||
await Permission.storage.request();
|
||||
|
||||
return microphoneStatus == PermissionStatus.granted ||
|
||||
microphoneStatus == PermissionStatus.limited;
|
||||
} catch (e) {
|
||||
throw VoiceRecordingException('Failed to request permissions: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> startRecording() async {
|
||||
try {
|
||||
if (_isRecording) {
|
||||
throw VoiceRecordingException('Recording is already in progress');
|
||||
}
|
||||
|
||||
final hasPermission = await requestPermissions();
|
||||
if (!hasPermission) {
|
||||
throw VoiceRecordingException('Microphone permission denied');
|
||||
}
|
||||
|
||||
final directory = await getTemporaryDirectory();
|
||||
_currentRecordingPath = '${directory.path}/voice_recording_${DateTime.now().millisecondsSinceEpoch}.wav';
|
||||
|
||||
await _recorder.start(
|
||||
const RecordConfig(
|
||||
encoder: AudioEncoder.wav,
|
||||
bitRate: 128000,
|
||||
sampleRate: 44100,
|
||||
),
|
||||
path: _currentRecordingPath!,
|
||||
);
|
||||
|
||||
_isRecording = true;
|
||||
} catch (e) {
|
||||
if (e is VoiceRecordingException) rethrow;
|
||||
throw VoiceRecordingException('Failed to start recording: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> stopRecording() async {
|
||||
try {
|
||||
if (!_isRecording) {
|
||||
throw VoiceRecordingException('No recording in progress');
|
||||
}
|
||||
|
||||
final path = await _recorder.stop();
|
||||
_isRecording = false;
|
||||
|
||||
if (path == null) {
|
||||
throw VoiceRecordingException('Failed to save recording');
|
||||
}
|
||||
|
||||
_currentRecordingPath = path;
|
||||
return path;
|
||||
} catch (e) {
|
||||
if (e is VoiceRecordingException) rethrow;
|
||||
throw VoiceRecordingException('Failed to stop recording: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> transcribeRecording({String? audioFilePath}) async {
|
||||
try {
|
||||
final filePath = audioFilePath ?? _currentRecordingPath;
|
||||
|
||||
if (filePath == null) {
|
||||
throw VoiceRecordingException('No audio file available for transcription');
|
||||
}
|
||||
|
||||
final file = File(filePath);
|
||||
if (!await file.exists()) {
|
||||
throw VoiceRecordingException('Audio file does not exist');
|
||||
}
|
||||
|
||||
final transcription = await _mistralService.transcribeAudio(
|
||||
audioFilePath: filePath,
|
||||
);
|
||||
|
||||
// Clean up the temporary file
|
||||
try {
|
||||
await file.delete();
|
||||
} catch (e) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
|
||||
_currentRecordingPath = null;
|
||||
return transcription;
|
||||
} catch (e) {
|
||||
if (e is VoiceRecordingException || e is MistralAIException) rethrow;
|
||||
throw VoiceRecordingException('Failed to transcribe recording: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> recordAndTranscribe() async {
|
||||
try {
|
||||
await startRecording();
|
||||
// Note: The caller should handle the timing of when to stop recording
|
||||
// This method is just a convenience wrapper
|
||||
throw VoiceRecordingException(
|
||||
'Use startRecording() and stopRecording() separately, then call transcribeRecording()',
|
||||
);
|
||||
} catch (e) {
|
||||
if (e is VoiceRecordingException) rethrow;
|
||||
throw VoiceRecordingException('Failed in record and transcribe flow: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancelRecording() async {
|
||||
try {
|
||||
if (_isRecording) {
|
||||
await _recorder.stop();
|
||||
_isRecording = false;
|
||||
|
||||
// Clean up the file if it exists
|
||||
if (_currentRecordingPath != null) {
|
||||
final file = File(_currentRecordingPath!);
|
||||
try {
|
||||
await file.delete();
|
||||
} catch (e) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
_currentRecordingPath = null;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
throw VoiceRecordingException('Failed to cancel recording: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
if (_isRecording) {
|
||||
cancelRecording();
|
||||
}
|
||||
_recorder.dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user