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:
Tomas Dvorak
2026-01-04 14:33:54 +01:00
parent 1a29315672
commit 37ffb93923
210 changed files with 29417 additions and 477 deletions
@@ -0,0 +1,140 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../data/models/achievement_model.dart';
import '../../../data/repositories/achievements_repository.dart';
import '../../../bootstrap/supabase_client.dart';
import '../../auth/application/auth_controller.dart';
class AchievementsState {
final bool isLoading;
final String? error;
final List<Achievement> availableAchievements;
final List<Achievement> unlockedAchievements;
final Achievement? newlyUnlocked;
const AchievementsState({
this.isLoading = false,
this.error,
this.availableAchievements = const [],
this.unlockedAchievements = const [],
this.newlyUnlocked,
});
int get unlockedCount => unlockedAchievements.length;
int get totalCount => availableAchievements.length;
double get completionPercentage =>
totalCount > 0 ? (unlockedCount / totalCount) * 100 : 0;
int get level {
if (unlockedCount <= 0) {
return 1;
}
return 1 + (unlockedCount ~/ 3);
}
AchievementsState copyWith({
bool? isLoading,
String? error,
List<Achievement>? availableAchievements,
List<Achievement>? unlockedAchievements,
Achievement? newlyUnlocked,
}) {
return AchievementsState(
isLoading: isLoading ?? this.isLoading,
error: error,
availableAchievements: availableAchievements ?? this.availableAchievements,
unlockedAchievements: unlockedAchievements ?? this.unlockedAchievements,
newlyUnlocked: newlyUnlocked,
);
}
}
class AchievementsController extends StateNotifier<AchievementsState> {
final AchievementsRepository _repository;
final AuthController _authController;
AchievementsController(
this._repository,
this._authController,
) : super(const AchievementsState()) {
_loadAchievements();
}
Future<void> _loadAchievements() async {
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);
state = state.copyWith(
isLoading: false,
availableAchievements: available,
unlockedAchievements: unlocked,
);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: e.toString(),
);
}
}
Future<Achievement?> checkAndUnlockAchievement(
AchievementType type,
int currentValue,
) async {
final userId = _authController.currentUserId;
if (userId == null) return null;
try {
final newlyUnlocked = await _repository.checkAndUnlockAchievement(
userId,
type,
currentValue,
);
if (newlyUnlocked != null) {
final updatedUnlocked = [...state.unlockedAchievements, newlyUnlocked];
state = state.copyWith(
unlockedAchievements: updatedUnlocked,
newlyUnlocked: newlyUnlocked,
);
return newlyUnlocked;
}
} catch (e) {
state = state.copyWith(error: e.toString());
}
return null;
}
void clearNewlyUnlocked() {
state = state.copyWith(newlyUnlocked: null);
}
void clearError() {
state = state.copyWith(error: null);
}
Future<void> refresh() async {
await _loadAchievements();
}
}
final achievementsControllerProvider =
StateNotifierProvider<AchievementsController, AchievementsState>((ref) {
final achievementsRepository = ref.watch(achievementsRepositoryProvider);
final authController = ref.watch(authControllerProvider.notifier);
return AchievementsController(
achievementsRepository,
authController,
);
});
final achievementsRepositoryProvider = Provider<AchievementsRepository>((ref) {
return AchievementsRepository(supabaseClient);
});
@@ -0,0 +1,208 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/widgets/app_scaffold.dart';
import '../../../core/widgets/loading_indicator.dart';
import '../../../core/widgets/empty_state.dart';
import '../application/achievements_controller.dart';
import '../../../data/models/achievement_model.dart';
class AchievementsScreen extends ConsumerStatefulWidget {
const AchievementsScreen({super.key});
@override
ConsumerState<AchievementsScreen> createState() => _AchievementsScreenState();
}
class _AchievementsScreenState extends ConsumerState<AchievementsScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(achievementsControllerProvider.notifier).refresh();
});
}
@override
Widget build(BuildContext context) {
final state = ref.watch(achievementsControllerProvider);
return AppScaffold(
title: 'Achievements',
body: state.isLoading
? const LoadingIndicator()
: state.error != null
? _buildError(state.error!)
: _buildContent(state),
);
}
Widget _buildError(String error) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Error loading achievements',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(error),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
ref.read(achievementsControllerProvider.notifier).refresh();
},
child: const Text('Retry'),
),
],
),
);
}
Widget _buildContent(AchievementsState state) {
if (state.availableAchievements.isEmpty) {
return const EmptyState(
icon: Icons.emoji_events_outlined,
title: 'No achievements available yet',
);
}
return RefreshIndicator(
onRefresh: () async {
await ref.read(achievementsControllerProvider.notifier).refresh();
},
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildProgressCard(state),
const SizedBox(height: 24),
_buildAchievementsList(state),
],
),
),
);
}
Widget _buildProgressCard(AchievementsState state) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Progress',
style: Theme.of(context).textTheme.titleLarge,
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'Level ${state.level}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 2),
Text(
'${state.unlockedCount}/${state.totalCount}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color:
Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
],
),
],
),
const SizedBox(height: 12),
LinearProgressIndicator(
value: state.completionPercentage / 100,
minHeight: 8,
borderRadius: BorderRadius.circular(4),
),
const SizedBox(height: 8),
Text(
'${state.completionPercentage.toStringAsFixed(0)}% Complete',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
Widget _buildAchievementsList(AchievementsState state) {
final unlockedIds = state.unlockedAchievements.map((a) => a.id).toSet();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'All Achievements',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
...state.availableAchievements.map((achievement) {
final isUnlocked = unlockedIds.contains(achievement.id);
return _buildAchievementCard(achievement, isUnlocked);
}),
],
);
}
Widget _buildAchievementCard(Achievement achievement, bool isUnlocked) {
return Opacity(
opacity: isUnlocked ? 1.0 : 0.6,
child: Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
leading: CircleAvatar(
backgroundColor: isUnlocked
? Theme.of(context).colorScheme.primaryContainer
: Colors.grey[200],
radius: 28,
child: Text(
achievement.icon,
style: const TextStyle(fontSize: 24),
),
),
title: Text(
achievement.title,
style: TextStyle(
fontWeight: FontWeight.bold,
color: isUnlocked
? Theme.of(context).colorScheme.onSurface
: Colors.grey,
),
),
subtitle: Text(
achievement.description,
style: TextStyle(
color: isUnlocked
? Theme.of(context).colorScheme.onSurfaceVariant
: Colors.grey,
),
),
trailing: isUnlocked
? Icon(
Icons.check_circle,
color: Theme.of(context).colorScheme.primary,
)
: const Icon(
Icons.lock_outline,
color: Colors.grey,
),
),
),
);
}
}
@@ -0,0 +1,296 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../data/services/mistral_ai_service.dart';
import '../../../data/services/voice_recording_service.dart';
import '../../../bootstrap/env.dart';
import '../../countdown/application/countdown_controller.dart';
import '../../goals/application/goals_controller.dart';
import '../../../core/utils/date_time_utils.dart';
final aiChatControllerProvider = StateNotifierProvider<AIChatController, AIChatState>((ref) {
final mistralService = MistralAIService(apiKey: Env.mistralApiKey);
final voiceService = VoiceRecordingService(mistralService: mistralService);
return AIChatController(ref, mistralService, voiceService);
});
class AIChatState {
final List<ChatMessage> messages;
final bool isLoading;
final bool isRecording;
final String? error;
final String? currentTranscription;
final bool privacyModeEnabled;
AIChatState({
this.messages = const [],
this.isLoading = false,
this.isRecording = false,
this.error,
this.currentTranscription,
this.privacyModeEnabled = true,
});
AIChatState copyWith({
List<ChatMessage>? messages,
bool? isLoading,
bool? isRecording,
String? error,
String? currentTranscription,
bool? privacyModeEnabled,
}) {
return AIChatState(
messages: messages ?? this.messages,
isLoading: isLoading ?? this.isLoading,
isRecording: isRecording ?? this.isRecording,
error: error ?? this.error,
currentTranscription: currentTranscription ?? this.currentTranscription,
privacyModeEnabled: privacyModeEnabled ?? this.privacyModeEnabled,
);
}
}
class AIChatController extends StateNotifier<AIChatState> {
final Ref _ref;
final MistralAIService _mistralService;
final VoiceRecordingService _voiceService;
static const String _privacyModePrefsKey = 'ai_chat_privacy_mode_enabled';
AIChatController(this._ref, this._mistralService, this._voiceService)
: super(AIChatState()) {
_loadPrivacyMode();
}
@override
void dispose() {
_voiceService.dispose();
_mistralService.dispose();
super.dispose();
}
void setPrivacyMode(bool enabled) {
state = state.copyWith(privacyModeEnabled: enabled);
_savePrivacyMode(enabled);
}
Future<void> _loadPrivacyMode() async {
try {
final prefs = await SharedPreferences.getInstance();
final stored = prefs.getBool(_privacyModePrefsKey);
if (stored != null) {
state = state.copyWith(privacyModeEnabled: stored);
}
} catch (_) {}
}
Future<void> _savePrivacyMode(bool enabled) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_privacyModePrefsKey, enabled);
} catch (_) {}
}
String _buildUserContextDescription() {
final countdownState = _ref.read(countdownControllerProvider);
final goalsState = _ref.read(goalsControllerProvider);
final user = countdownState.user;
if (user == null) {
if (state.privacyModeEnabled) {
return 'User privacy mode is ENABLED. No countdown data is available yet.';
}
return 'User privacy mode is DISABLED, but no countdown data could be loaded yet.';
}
final now = DateTime.now();
final start = user.countdownStartDate;
final end = user.countdownEndDate;
String? countdownSummary;
int? currentDay;
int? daysRemaining;
if (start != null && end != null) {
final isFinished = DateTimeUtils.isCountdownFinished(end);
if (isFinished) {
countdownSummary =
'Their 1356-day countdown challenge has already finished.';
} else {
final remainingDuration = DateTimeUtils.calculateRemainingTime(end);
daysRemaining = remainingDuration.inDays;
final totalDurationDays = end.difference(start).inDays;
final elapsedDays = now.difference(start).inDays;
if (totalDurationDays > 0) {
currentDay = elapsedDays + 1;
}
final formattedRemaining =
DateTimeUtils.formatCountdownCompact(remainingDuration);
if (currentDay != null) {
countdownSummary =
'Currently on day $currentDay of ${DateTimeUtils.countdownDays} with about $formattedRemaining remaining (approximately $daysRemaining days left).';
} else {
countdownSummary =
'A 1356-day countdown challenge is active with about $formattedRemaining remaining.';
}
}
}
if (state.privacyModeEnabled) {
if (countdownSummary != null) {
return 'User privacy mode is ENABLED. Only basic countdown information is shared. $countdownSummary';
}
return 'User privacy mode is ENABLED. The user has not started their 1356-day countdown yet.';
}
final buffer = StringBuffer();
buffer.writeln(
'User privacy mode is DISABLED. Use the following personal context to personalise your coaching:');
buffer.writeln('Username: ${user.username}.');
if (countdownSummary != null) {
buffer.writeln(countdownSummary);
} else {
buffer.writeln(
'The user has not started their 1356-day countdown challenge yet.');
}
final goals = goalsState.goals;
if (goals.isNotEmpty) {
buffer.writeln(
'The user has ${goals.length} active bucket list goals. Here are some examples:');
for (final goal in goals.take(3)) {
buffer.writeln(
'- Goal: "${goal.title}" (progress: ${goal.progress}%, completed: ${goal.completed}).');
}
final completedGoalsCount = goals.where((g) => g.completed).length;
if (completedGoalsCount > 0) {
buffer.writeln(
'They have completed $completedGoalsCount goals so far in their challenge.');
}
} else {
buffer.writeln(
'The user currently has no saved goals, or they could not be loaded.');
}
return buffer.toString();
}
Future<void> sendMessage(String message) async {
if (message.trim().isEmpty || state.isLoading) return;
final userMessage = ChatMessage(
content: message.trim(),
role: 'user',
);
state = state.copyWith(
messages: [...state.messages, userMessage],
isLoading: true,
error: null,
);
try {
final userContextDescription = _buildUserContextDescription();
final response = await _mistralService.chat(
message: message,
conversationHistory: state.messages,
userContext: userContextDescription,
);
final aiMessage = ChatMessage(
content: response,
role: 'assistant',
);
state = state.copyWith(
messages: [...state.messages, aiMessage],
isLoading: false,
);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: e.toString(),
);
}
}
Future<void> startRecording() async {
if (state.isRecording || state.isLoading) return;
try {
await _voiceService.startRecording();
state = state.copyWith(isRecording: true, error: null);
} catch (e) {
state = state.copyWith(error: e.toString());
}
}
Future<void> stopRecording() async {
if (!state.isRecording) return;
state = state.copyWith(isRecording: false, isLoading: true);
try {
final audioPath = await _voiceService.stopRecording();
if (audioPath.isNotEmpty) {
state = state.copyWith(currentTranscription: 'Transcribing...');
final transcription = await _voiceService.transcribeRecording(
audioFilePath: audioPath,
);
state = state.copyWith(currentTranscription: null);
if (transcription.isNotEmpty) {
await sendMessage(transcription);
} else {
state = state.copyWith(
isLoading: false,
error: 'No speech detected. Please try again.',
);
}
} else {
state = state.copyWith(
isLoading: false,
error: 'Failed to save recording',
);
}
} catch (e) {
state = state.copyWith(
isLoading: false,
isRecording: false,
error: e.toString(),
);
}
}
Future<void> cancelRecording() async {
if (!state.isRecording) return;
try {
await _voiceService.cancelRecording();
state = state.copyWith(isRecording: false);
} catch (e) {
state = state.copyWith(error: e.toString());
}
}
void clearError() {
state = state.copyWith(error: null);
}
void clearMessages() {
state = state.copyWith(messages: []);
}
}
@@ -0,0 +1,469 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../application/ai_chat_controller.dart';
class AIChatScreen extends ConsumerStatefulWidget {
const AIChatScreen({super.key});
@override
ConsumerState<AIChatScreen> createState() => _AIChatScreenState();
}
class _AIChatScreenState extends ConsumerState<AIChatScreen> {
final TextEditingController _textController = TextEditingController();
final ScrollController _scrollController = ScrollController();
@override
void dispose() {
_textController.dispose();
_scrollController.dispose();
super.dispose();
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
}
@override
Widget build(BuildContext context) {
final state = ref.watch(aiChatControllerProvider);
final controller = ref.read(aiChatControllerProvider.notifier);
// Auto-scroll when new messages arrive
ref.listen<AIChatState>(aiChatControllerProvider, (previous, next) {
if (next.messages.length > (previous?.messages.length ?? 0)) {
_scrollToBottom();
}
});
return Scaffold(
appBar: AppBar(
title: const Text('AI Life Coach'),
backgroundColor: Theme.of(context).colorScheme.surface,
foregroundColor: Theme.of(context).colorScheme.onSurface,
elevation: 0,
actions: [
IconButton(
icon: const Icon(Icons.clear_all),
onPressed: () => controller.clearMessages(),
tooltip: 'Clear chat',
),
],
),
body: Column(
children: [
// Messages list
Expanded(
child: state.messages.isEmpty
? _buildEmptyState(context)
: _buildMessagesList(state.messages),
),
// Error message
if (state.error != null) _buildErrorMessage(state.error!, controller),
// Transcription indicator
if (state.currentTranscription != null)
_buildTranscriptionIndicator(state.currentTranscription!),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Row(
children: [
Expanded(
child: Text(
state.privacyModeEnabled
? 'Privacy mode on · Only share countdown day and time remaining with the AI.'
: 'Privacy mode off · Also share your username and goal progress for more personal coaching.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha:0.7),
),
),
),
const SizedBox(width: 8),
Switch.adaptive(
value: state.privacyModeEnabled,
onChanged: controller.setPrivacyMode,
),
],
),
),
// Input area
_buildInputArea(state, controller),
],
),
);
}
Widget _buildEmptyState(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.psychology,
size: 80,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 16),
Text(
'Your AI Life Coach',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
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),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
children: [
_buildSuggestionChip('Give me bucket list ideas'),
const SizedBox(height: 8),
_buildSuggestionChip('How to stay motivated?'),
const SizedBox(height: 8),
_buildSuggestionChip('Help me set meaningful goals'),
],
),
),
],
),
);
}
Widget _buildSuggestionChip(String text) {
return InkWell(
onTap: () {
_textController.text = text;
ref.read(aiChatControllerProvider.notifier).sendMessage(text);
_textController.clear();
},
borderRadius: BorderRadius.circular(20),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Text(
text,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
);
}
Widget _buildMessagesList(List messages) {
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(16),
itemCount: messages.length,
itemBuilder: (context, index) {
final message = messages[index];
final isUser = message.role == 'user';
return _buildMessageBubble(message, isUser);
},
);
}
Widget _buildMessageBubble(message, bool isUser) {
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Row(
mainAxisAlignment: isUser ? MainAxisAlignment.end : MainAxisAlignment.start,
children: [
if (!isUser) ...[
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),
],
Flexible(
child: Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.75,
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: isUser
? Theme.of(context).colorScheme.primary
: 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,
),
),
),
),
if (isUser) ...[
const SizedBox(width: 8),
CircleAvatar(
radius: 16,
backgroundColor: Theme.of(context).colorScheme.secondary,
child: Icon(
Icons.person,
size: 20,
color: Theme.of(context).colorScheme.onSecondary,
),
),
],
],
),
);
}
Widget _buildErrorMessage(String error, controller) {
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.error_outline,
color: Theme.of(context).colorScheme.onErrorContainer,
),
const SizedBox(width: 8),
Expanded(
child: Text(
error,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onErrorContainer,
),
),
),
IconButton(
icon: const Icon(Icons.close, size: 16),
onPressed: controller.clearError,
color: Theme.of(context).colorScheme.onErrorContainer,
),
],
),
);
}
Widget _buildTranscriptionIndicator(String text) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
),
),
const SizedBox(width: 8),
Text(
text,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
],
),
);
}
Widget _buildInputArea(AIChatState state, controller) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [
BoxShadow(
color: Theme.of(context).shadowColor.withValues(alpha:0.1),
blurRadius: 8,
offset: const Offset(0, -2),
),
],
),
child: Column(
children: [
// Voice recording indicator
if (state.isRecording) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.mic,
color: Theme.of(context).colorScheme.onErrorContainer,
),
const SizedBox(width: 8),
Text(
'Recording... Tap to stop',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onErrorContainer,
),
),
],
),
),
const SizedBox(height: 8),
],
// Input row
Row(
children: [
// Voice button
Container
(
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),
),
child: IconButton(
onPressed: state.isRecording
? () => controller.stopRecording()
: () => controller.startRecording(),
icon: Icon(
state.isRecording ? Icons.stop : Icons.mic,
color: state.isRecording
? Theme.of(context).colorScheme.error
: Theme.of(context).colorScheme.primary,
),
tooltip:
state.isRecording ? 'Stop recording' : 'Start voice input',
),
),
// Text field
Expanded(
child: TextField(
controller: _textController,
enabled: !state.isLoading && !state.isRecording,
decoration: InputDecoration(
hintText: state.isRecording
? 'Recording voice...'
: 'Ask for advice or inspiration...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.outline,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
maxLines: null,
textInputAction: TextInputAction.send,
onSubmitted: (value) {
if (value.trim().isNotEmpty && !state.isLoading) {
controller.sendMessage(value);
_textController.clear();
}
},
),
),
const SizedBox(width: 8),
// Send button
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha:state.isLoading ||
_textController.text.trim().isEmpty
? 0.06
: 0.12),
),
child: IconButton(
onPressed:
state.isLoading || _textController.text.trim().isEmpty
? null
: () {
controller.sendMessage(_textController.text);
_textController.clear();
},
icon: state.isLoading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
),
)
: Icon(
Icons.send,
color: Theme.of(context).colorScheme.primary,
),
tooltip: 'Send message',
),
),
],
),
],
),
);
}
}
@@ -0,0 +1,279 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../data/models/goal_model.dart';
import '../../../data/repositories/goals_repository.dart';
import '../../../data/repositories/countdown_repository.dart';
import '../../../bootstrap/supabase_client.dart';
import '../../auth/application/auth_controller.dart';
class InsightsState {
final bool isLoading;
final String? error;
final List<Goal> goals;
final int totalGoals;
final int completedGoals;
final int activeGoals;
final double overallProgress;
final int currentStreak;
final int longestStreak;
final DateTime? countdownStartDate;
final DateTime? countdownEndDate;
final int daysRemaining;
final double timeElapsedPercentage;
const InsightsState({
this.isLoading = false,
this.error,
this.goals = const [],
this.totalGoals = 0,
this.completedGoals = 0,
this.activeGoals = 0,
this.overallProgress = 0.0,
this.currentStreak = 0,
this.longestStreak = 0,
this.countdownStartDate,
this.countdownEndDate,
this.daysRemaining = 0,
this.timeElapsedPercentage = 0.0,
});
InsightsState copyWith({
bool? isLoading,
String? error,
List<Goal>? goals,
int? totalGoals,
int? completedGoals,
int? activeGoals,
double? overallProgress,
int? currentStreak,
int? longestStreak,
DateTime? countdownStartDate,
DateTime? countdownEndDate,
int? daysRemaining,
double? timeElapsedPercentage,
}) {
return InsightsState(
isLoading: isLoading ?? this.isLoading,
error: error,
goals: goals ?? this.goals,
totalGoals: totalGoals ?? this.totalGoals,
completedGoals: completedGoals ?? this.completedGoals,
activeGoals: activeGoals ?? this.activeGoals,
overallProgress: overallProgress ?? this.overallProgress,
currentStreak: currentStreak ?? this.currentStreak,
longestStreak: longestStreak ?? this.longestStreak,
countdownStartDate: countdownStartDate ?? this.countdownStartDate,
countdownEndDate: countdownEndDate ?? this.countdownEndDate,
daysRemaining: daysRemaining ?? this.daysRemaining,
timeElapsedPercentage: timeElapsedPercentage ?? this.timeElapsedPercentage,
);
}
}
class InsightsController extends StateNotifier<InsightsState> {
final GoalsRepository _goalsRepository;
final CountdownRepository _countdownRepository;
final AuthController _authController;
InsightsController(
this._goalsRepository,
this._countdownRepository,
this._authController,
) : super(const InsightsState()) {
_loadInsights();
}
Future<void> _loadInsights() async {
final userId = _authController.currentUserId;
if (userId == null) return;
state = state.copyWith(isLoading: true);
try {
final goals = await _goalsRepository.getGoals(userId);
final countdown = await _countdownRepository.getCountdownInfo(userId);
final totalGoals = goals.length;
final completedGoals = goals.where((g) => g.completed).length;
final activeGoals = totalGoals - completedGoals;
final overallProgress = totalGoals > 0
? (completedGoals / totalGoals) * 100
: 0.0;
final currentStreak = _calculateCurrentStreak(goals);
final longestStreak = _calculateLongestStreak(goals);
final daysRemaining = countdown.daysRemaining ?? 0;
final totalDays = countdown.countdownEndDate != null && countdown.countdownStartDate != null
? countdown.countdownEndDate!.difference(countdown.countdownStartDate!).inDays
: 0;
final elapsedDays = countdown.countdownStartDate != null
? DateTime.now().difference(countdown.countdownStartDate!).inDays.clamp(0, totalDays)
: 0;
final timeElapsedPercentage = totalDays > 0
? (elapsedDays / totalDays) * 100
: 0.0;
state = state.copyWith(
isLoading: false,
goals: goals,
totalGoals: totalGoals,
completedGoals: completedGoals,
activeGoals: activeGoals,
overallProgress: overallProgress,
currentStreak: currentStreak,
longestStreak: longestStreak,
countdownStartDate: countdown.countdownStartDate,
countdownEndDate: countdown.countdownEndDate,
daysRemaining: daysRemaining,
timeElapsedPercentage: timeElapsedPercentage,
);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: e.toString(),
);
}
}
int _calculateCurrentStreak(List<Goal> goals) {
if (goals.isEmpty) return 0;
final now = DateTime.now();
int streak = 0;
DateTime currentDate = now;
for (int i = 0; i < 365; i++) {
final hasActivityOnDay = goals.any((goal) {
final updatedDate = goal.updatedAt;
return updatedDate.year == currentDate.year &&
updatedDate.month == currentDate.month &&
updatedDate.day == currentDate.day;
});
if (hasActivityOnDay) {
streak++;
currentDate = currentDate.subtract(const Duration(days: 1));
} else {
break;
}
}
return streak;
}
int _calculateLongestStreak(List<Goal> goals) {
if (goals.isEmpty) return 0;
final allDates = goals
.map((g) => g.updatedAt)
.whereType<DateTime>()
.toSet()
.toList()
..sort();
if (allDates.isEmpty) return 0;
int longestStreak = 1;
int currentStreak = 1;
for (int i = 1; i < allDates.length; i++) {
final difference = allDates[i].difference(allDates[i - 1]).inDays;
if (difference == 1) {
currentStreak++;
} else if (difference > 1) {
longestStreak = longestStreak > currentStreak ? longestStreak : currentStreak;
currentStreak = 1;
}
}
return longestStreak > currentStreak ? longestStreak : currentStreak;
}
List<Map<String, dynamic>> getGoalCompletionTrends() {
final goals = state.goals;
if (goals.isEmpty) return [];
final now = DateTime.now();
final trends = <Map<String, dynamic>>[];
for (int i = 6; i >= 0; i--) {
final weekStart = now.subtract(Duration(days: i * 7));
final weekEnd = weekStart.add(const Duration(days: 7));
final completedInWeek = goals.where((goal) {
final updated = goal.updatedAt;
return goal.completed &&
updated.isAfter(weekStart) &&
updated.isBefore(weekEnd);
}).length;
trends.add({
'week': 'Week ${7 - i}',
'completed': completedInWeek,
});
}
return trends;
}
List<Map<String, dynamic>> getProgressVsTimeData() {
if (state.countdownStartDate == null || state.countdownEndDate == null) {
return [];
}
final start = state.countdownStartDate!;
final end = state.countdownEndDate!;
final totalDays = end.difference(start).inDays;
final elapsedDays = DateTime.now().difference(start).inDays.clamp(0, totalDays);
final data = <Map<String, dynamic>>[];
const int intervals = 10;
for (int i = 0; i <= intervals; i++) {
final day = (totalDays * i / intervals).round();
final date = start.add(Duration(days: day));
final expectedProgress = (i / intervals) * 100;
final actualProgress = i <= (elapsedDays / totalDays * intervals).round()
? state.overallProgress
: 0.0;
data.add({
'day': day,
'date': date,
'expected': expectedProgress,
'actual': actualProgress,
});
}
return data;
}
void clearError() {
state = state.copyWith(error: null);
}
Future<void> refresh() async {
await _loadInsights();
}
}
final insightsControllerProvider =
StateNotifierProvider<InsightsController, InsightsState>((ref) {
final goalsRepository = ref.watch(goalsRepositoryProvider);
final countdownRepository = ref.watch(countdownRepositoryProvider);
final authController = ref.watch(authControllerProvider.notifier);
return InsightsController(
goalsRepository,
countdownRepository,
authController,
);
});
final goalsRepositoryProvider = Provider<GoalsRepository>((ref) {
return GoalsRepository(supabaseClient);
});
final countdownRepositoryProvider = Provider<CountdownRepository>((ref) {
return CountdownRepository(supabaseClient);
});
@@ -0,0 +1,421 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fl_chart/fl_chart.dart';
import '../../../core/widgets/app_scaffold.dart';
import '../../../core/widgets/loading_indicator.dart';
import '../../../core/widgets/empty_state.dart';
import '../application/insights_controller.dart';
class InsightsScreen extends ConsumerStatefulWidget {
const InsightsScreen({super.key});
@override
ConsumerState<InsightsScreen> createState() => _InsightsScreenState();
}
class _InsightsScreenState extends ConsumerState<InsightsScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(insightsControllerProvider.notifier).refresh();
});
}
@override
Widget build(BuildContext context) {
final state = ref.watch(insightsControllerProvider);
return AppScaffold(
title: 'Insights',
body: state.isLoading
? const LoadingIndicator()
: state.error != null
? _buildError(state.error!)
: _buildContent(state),
);
}
Widget _buildError(String error) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Error loading insights',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(error),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
ref.read(insightsControllerProvider.notifier).refresh();
},
child: const Text('Retry'),
),
],
),
);
}
Widget _buildContent(InsightsState state) {
if (state.totalGoals == 0) {
return const EmptyState(
icon: Icons.insights_outlined,
title: 'No data yet',
subtitle: 'Start creating goals to see your insights',
);
}
return RefreshIndicator(
onRefresh: () async {
await ref.read(insightsControllerProvider.notifier).refresh();
},
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildOverviewCards(state),
const SizedBox(height: 24),
_buildProgressChart(state),
const SizedBox(height: 24),
_buildGoalCompletionTrends(state),
const SizedBox(height: 24),
_buildStreakCard(state),
],
),
),
);
}
Widget _buildOverviewCards(InsightsState state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Overview',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _buildStatCard(
'Total Goals',
state.totalGoals.toString(),
Icons.flag_outlined,
Theme.of(context).colorScheme.primary,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildStatCard(
'Completed',
state.completedGoals.toString(),
Icons.check_circle_outline,
Colors.green,
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildStatCard(
'Active',
state.activeGoals.toString(),
Icons.pending_outlined,
Colors.orange,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildStatCard(
'Progress',
'${state.overallProgress.toStringAsFixed(0)}%',
Icons.trending_up,
Colors.blue,
),
),
],
),
],
);
}
Widget _buildStatCard(String title, String value, IconData icon, Color color) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Icon(icon, color: color, size: 32),
const SizedBox(height: 8),
Text(
value,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: color,
),
),
const SizedBox(height: 4),
Text(
title,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
Widget _buildProgressChart(InsightsState state) {
final trends = ref.read(insightsControllerProvider.notifier).getProgressVsTimeData();
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Progress vs Time',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
SizedBox(
height: 200,
child: trends.isEmpty
? const Center(child: Text('No countdown data available'))
: LineChart(
LineChartData(
gridData: const FlGridData(show: true),
titlesData: FlTitlesData(
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 40,
getTitlesWidget: (value, meta) {
if (value % 20 == 0) {
return Text(
'${value.toInt()}%',
style: const TextStyle(fontSize: 10),
);
}
return const Text('');
},
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 30,
getTitlesWidget: (value, meta) {
if (value.toInt() % 2 == 0) {
return Text(
'Day ${value.toInt()}',
style: const TextStyle(fontSize: 10),
);
}
return const Text('');
},
),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
borderData: FlBorderData(show: true),
lineBarsData: [
LineChartBarData(
spots: trends
.map((t) => FlSpot(t['day'].toDouble(), t['expected'].toDouble()))
.toList(),
isCurved: true,
color: Colors.grey[400],
barWidth: 2,
dotData: const FlDotData(show: false),
),
LineChartBarData(
spots: trends
.map((t) => FlSpot(t['day'].toDouble(), t['actual'].toDouble()))
.toList(),
isCurved: true,
color: Theme.of(context).colorScheme.primary,
barWidth: 3,
dotData: const FlDotData(show: true),
),
],
),
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildLegendItem('Expected', Colors.grey[400]!),
const SizedBox(width: 16),
_buildLegendItem('Actual', Theme.of(context).colorScheme.primary),
],
),
],
),
),
);
}
Widget _buildLegendItem(String label, Color color) {
return Row(
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
const SizedBox(width: 4),
Text(
label,
style: Theme.of(context).textTheme.bodySmall,
),
],
);
}
Widget _buildGoalCompletionTrends(InsightsState state) {
final trends = ref.read(insightsControllerProvider.notifier).getGoalCompletionTrends();
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Weekly Completion Trends',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
SizedBox(
height: 200,
child: trends.isEmpty
? const Center(child: Text('No data available'))
: BarChart(
BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: trends.map((t) => t['completed'] as int).reduce((a, b) => a > b ? a : b).toDouble() + 1,
barGroups: trends.asMap().entries.map((entry) {
return BarChartGroupData(
x: entry.key,
barRods: [
BarChartRodData(
toY: entry.value['completed'].toDouble(),
color: Theme.of(context).colorScheme.primary,
width: 20,
borderRadius: BorderRadius.circular(4),
),
],
);
}).toList(),
titlesData: FlTitlesData(
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 40,
getTitlesWidget: (value, meta) {
if (value == value.toInt()) {
return Text(
value.toInt().toString(),
style: const TextStyle(fontSize: 10),
);
}
return const Text('');
},
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 30,
getTitlesWidget: (value, meta) {
if (value >= 0 && value < trends.length) {
return Text(
trends[value.toInt()]['week'] as String,
style: const TextStyle(fontSize: 10),
);
}
return const Text('');
},
),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
borderData: FlBorderData(show: true),
),
),
),
],
),
),
);
}
Widget _buildStreakCard(InsightsState state) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
const Icon(
Icons.local_fire_department,
size: 48,
color: Colors.orange,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Current Streak',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
'${state.currentStreak} days',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.orange,
),
),
const SizedBox(height: 8),
Text(
'Longest: ${state.longestStreak} days',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
);
}
}
@@ -1,17 +1,19 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../data/repositories/auth_repository.dart';
import '../../../data/models/user_model.dart';
import '../../../core/services/analytics_service.dart';
final authControllerProvider = StateNotifierProvider<AuthController, User?>((ref) {
return AuthController(ref.read(authRepositoryProvider));
});
final authRepositoryProvider = Provider<AuthRepository>((ref) {
return AuthRepository(/* SupabaseClient instance will be injected */);
return AuthRepository();
});
class AuthController extends StateNotifier<User?> {
final AuthRepository _authRepository;
final AnalyticsService _analytics = AnalyticsService();
AuthController(this._authRepository) : super(null) {
_init();
@@ -21,27 +23,54 @@ class AuthController extends StateNotifier<User?> {
state = _authRepository.currentUser;
_authRepository.authStateChanges.listen((user) {
state = user;
if (user != null) {
_analytics.setUserId(user.id);
}
});
}
bool get isAuthenticated => _authRepository.isAuthenticated;
String? get currentUserId => _authRepository.currentUserId;
Future<bool> isSessionValid() async {
return await _authRepository.isSessionValid();
}
Future<void> refreshSession() async {
await _authRepository.refreshSession();
}
Future<void> signInWithEmail(String email, String password) async {
await _authRepository.signInWithEmail(email, password);
_analytics.logSignIn(method: 'email');
}
Future<void> signUpWithEmail(String email, String password, String username) async {
await _authRepository.signUpWithEmail(email, password, username);
_analytics.logSignUp(method: 'email');
}
Future<void> signInWithGoogle() async {
await _authRepository.signInWithGoogle();
_analytics.logSignIn(method: 'google');
}
Future<void> signInWithApple() async {
await _authRepository.signInWithApple();
_analytics.logSignIn(method: 'apple');
}
Future<void> signInWithGithub() async {
await _authRepository.signInWithGithub();
_analytics.logSignIn(method: 'github');
}
Future<void> signOut() async {
await _authRepository.signOut();
state = null;
_analytics.logSignOut();
_analytics.reset();
}
Future<void> resetPassword(String email) async {
@@ -54,11 +83,30 @@ class AuthController extends StateNotifier<User?> {
String? avatarUrl,
bool? isPublicProfile,
}) async {
final updatedFields = <String>[];
if (username != null) updatedFields.add('username');
if (bio != null) updatedFields.add('bio');
if (avatarUrl != null) updatedFields.add('avatar');
if (isPublicProfile != null) {
updatedFields.add('visibility');
_analytics.logProfileVisibilityChanged(isPublic: isPublicProfile);
}
await _authRepository.updateProfile(
username: username,
bio: bio,
avatarUrl: avatarUrl,
isPublicProfile: isPublicProfile,
);
if (updatedFields.isNotEmpty) {
_analytics.logProfileUpdated(fieldsUpdated: updatedFields.join(','));
}
}
@override
void dispose() {
_authRepository.dispose();
super.dispose();
}
}
@@ -0,0 +1,218 @@
// ignore_for_file: deprecated_member_use
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/widgets/app_scaffold.dart';
import '../../../core/widgets/primary_button.dart';
import '../application/auth_controller.dart';
class AuthChoiceScreen extends ConsumerStatefulWidget {
const AuthChoiceScreen({super.key});
@override
ConsumerState<AuthChoiceScreen> createState() => _AuthChoiceScreenState();
}
class _AuthChoiceScreenState extends ConsumerState<AuthChoiceScreen> {
bool _isLoading = false;
Future<void> _handleGoogleSignIn() async {
setState(() => _isLoading = true);
try {
await ref.read(authControllerProvider.notifier).signInWithGoogle();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Google sign-in failed: $e')),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
Future<void> _handleAppleSignIn() async {
setState(() => _isLoading = true);
try {
await ref.read(authControllerProvider.notifier).signInWithApple();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Apple sign-in failed: $e')),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
Future<void> _handleGithubSignIn() async {
setState(() => _isLoading = true);
try {
await ref.read(authControllerProvider.notifier).signInWithGithub();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('GitHub sign-in failed: $e')),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
@override
Widget build(BuildContext context) {
return AppScaffold(
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 32,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(32),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.06),
blurRadius: 40,
offset: const Offset(0, 24),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Align(
alignment: Alignment.centerLeft,
child: Container(
height: 56,
width: 56,
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.primary
.withOpacity(0.08),
shape: BoxShape.circle,
),
child: Icon(
Icons.timer_outlined,
size: 32,
color: Theme.of(context).colorScheme.primary,
),
),
),
const SizedBox(height: 24),
Text(
'LifeTimer',
style: Theme.of(context)
.textTheme
.headlineLarge
?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.left,
),
const SizedBox(height: 8),
Text(
'Your 1356-day journey starts here',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.7),
),
),
const SizedBox(height: 32),
PrimaryButton(
onPressed: () => context.push('/sign-in'),
text: 'Sign In with Email',
isLoading: _isLoading,
),
const SizedBox(height: 12),
OutlinedButton(
onPressed:
_isLoading ? null : () => context.push('/sign-up'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(999),
),
),
child: const Text('Create Account'),
),
const SizedBox(height: 24),
_SocialButton(
icon: Icons.g_mobiledata,
label: 'Continue with Google',
isLoading: _isLoading,
onPressed: _handleGoogleSignIn,
),
const SizedBox(height: 12),
_SocialButton(
icon: Icons.apple,
label: 'Continue with Apple',
isLoading: _isLoading,
onPressed: _handleAppleSignIn,
),
const SizedBox(height: 12),
_SocialButton(
icon: Icons.code,
label: 'Continue with GitHub',
isLoading: _isLoading,
onPressed: _handleGithubSignIn,
),
],
),
),
),
),
),
),
);
}
}
class _SocialButton extends StatelessWidget {
final IconData icon;
final String label;
final bool isLoading;
final VoidCallback onPressed;
const _SocialButton({
required this.icon,
required this.label,
required this.isLoading,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return ElevatedButton.icon(
onPressed: isLoading ? null : onPressed,
icon: Icon(icon, size: 24),
label: Text(label),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
);
}
}
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../application/auth_controller.dart';
import 'auth_showcase_screen.dart';
import '../../onboarding/presentation/onboarding_intro_screen.dart';
class AuthGate extends ConsumerWidget {
@@ -9,126 +10,11 @@ class AuthGate extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authControllerProvider);
if (authState == null) {
return const SignInScreen();
return const AuthShowcaseScreen();
}
return const OnboardingIntroScreen();
}
}
class SignInScreen extends ConsumerStatefulWidget {
const SignInScreen({super.key});
@override
ConsumerState<SignInScreen> createState() => _SignInScreenState();
}
class _SignInScreenState extends ConsumerState<SignInScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _isLoading = false;
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _signIn() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
await ref.read(authControllerProvider.notifier).signInWithEmail(
_emailController.text.trim(),
_passwordController.text,
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('LifeTimer'),
),
body: Padding(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Welcome Back',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your email';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
decoration: const InputDecoration(
labelText: 'Password',
border: OutlineInputBorder(),
),
obscureText: true,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your password';
}
return null;
},
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _isLoading ? null : _signIn,
child: _isLoading
? const CircularProgressIndicator()
: const Text('Sign In'),
),
const SizedBox(height: 16),
TextButton(
onPressed: () {
// Navigate to sign up
},
child: const Text('Don\'t have an account? Sign Up'),
),
],
),
),
),
);
}
}
@@ -0,0 +1,22 @@
import 'package:flutter/material.dart';
import '../../../core/widgets/app_scaffold.dart';
class AuthLoadingScreen extends StatelessWidget {
const AuthLoadingScreen({super.key});
@override
Widget build(BuildContext context) {
return const AppScaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Signing you in...'),
],
),
),
);
}
}
@@ -0,0 +1,246 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/widgets/app_scaffold.dart';
import '../../../core/widgets/primary_button.dart';
class AuthShowcaseScreen extends ConsumerWidget {
const AuthShowcaseScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return AppScaffold(
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container
(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(999),
border: Border.all(
color:
colorScheme.onSurface.withValues(alpha:0.06),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: colorScheme.primary,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text(
'1356 days. One focused challenge.',
style: theme.textTheme.labelMedium,
),
],
),
),
const SizedBox(height: 24),
Text(
'Make every day\ncount down.',
style: theme.textTheme.displaySmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 16),
Text(
'LifeTimer helps you design a 1356-day experiment, focus on a small set of meaningful goals, and see time as a single bold countdown.',
style: theme.textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withValues(alpha:0.7),
height: 1.6,
),
),
const SizedBox(height: 32),
const Row(
children: [
_ShowcaseStatCard(
label: 'Days in your challenge',
value: '1356',
),
SizedBox(width: 12),
_ShowcaseStatCard(
label: 'Goals you can track',
value: '1 - 20',
),
],
),
const SizedBox(height: 16),
const _ShowcaseFeatureCard(
icon: Icons.flag_outlined,
title: 'Set sharp goals',
description:
'Capture a concise bucket list that is realistic but ambitious.',
),
const SizedBox(height: 12),
const _ShowcaseFeatureCard(
icon: Icons.timer_outlined,
title: 'See the countdown',
description:
'A single timer keeps you aware of how many days are left.',
),
const SizedBox(height: 12),
const _ShowcaseFeatureCard(
icon: Icons.trending_up,
title: 'Track your progress',
description:
'Reflect on wins, see streaks, and keep momentum over years.',
),
const SizedBox(height: 32),
PrimaryButton(
text: 'Start your 1356-day journey',
onPressed: () => context.push('/auth-choice'),
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: () => context.push('/sign-in'),
child: const Text('Already have an account? Sign in'),
),
),
],
),
),
),
),
),
);
}
}
class _ShowcaseStatCard extends StatelessWidget {
final String label;
final String value;
const _ShowcaseStatCard({
required this.label,
required this.value,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Expanded(
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: colorScheme.onSurface.withValues(alpha:0.06),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
value,
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha:0.6),
),
),
],
),
),
);
}
}
class _ShowcaseFeatureCard extends StatelessWidget {
final IconData icon;
final String title;
final String description;
const _ShowcaseFeatureCard({
required this.icon,
required this.title,
required this.description,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: colorScheme.onSurface.withValues(alpha:0.06),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: colorScheme.primary.withValues(alpha:0.06),
shape: BoxShape.circle,
),
child: Icon(
icon,
color: colorScheme.primary,
size: 22,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
description,
style: theme.textTheme.bodyMedium?.copyWith(
color:
colorScheme.onSurface.withValues(alpha:0.7),
height: 1.5,
),
),
],
),
),
],
),
);
}
}
@@ -0,0 +1,272 @@
// ignore_for_file: deprecated_member_use
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.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});
@override
ConsumerState<SignInScreen> createState() => _SignInScreenState();
}
class _SignInScreenState extends ConsumerState<SignInScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _isLoading = false;
bool _obscurePassword = true;
Future<void> _handleSignIn() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
await ref.read(authControllerProvider.notifier).signInWithEmail(
_emailController.text.trim(),
_passwordController.text,
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Sign in failed: $e')),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
Future<void> _handleResetPassword() async {
if (_emailController.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please enter your email address')),
);
return;
}
try {
await ref.read(authControllerProvider.notifier).resetPassword(
_emailController.text.trim(),
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Password reset email sent')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to send reset email: $e')),
);
}
}
}
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AppScaffold(
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 32,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(32),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.06),
blurRadius: 40,
offset: const Offset(0, 24),
),
],
),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'LifeTimer',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
fontWeight: FontWeight.w600,
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.08),
),
),
child: Text(
'Sign in',
style: Theme.of(context)
.textTheme
.labelMedium
?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.8),
),
),
),
],
),
const SizedBox(height: 24),
Text(
'Welcome back',
style: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Sign in to continue your journey',
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.7),
),
),
const SizedBox(height: 24),
Semantics(
label: 'Email address field',
hint: 'Enter your email address',
child: TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email_outlined),
),
validator: Validators.validateEmail,
enabled: !_isLoading,
),
),
const SizedBox(height: 16),
Semantics(
label: 'Password field',
hint: 'Enter your password',
child: TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
textInputAction: TextInputAction.done,
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: const Icon(Icons.lock_outlined),
suffixIcon: Semantics(
button: true,
label: _obscurePassword
? 'Show password'
: 'Hide password',
child: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
onPressed: () {
setState(
() => _obscurePassword =
!_obscurePassword,
);
},
),
),
),
validator: Validators.validatePassword,
enabled: !_isLoading,
onFieldSubmitted: (_) => _handleSignIn(),
),
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: Semantics(
button: true,
label: 'Forgot password button',
hint: 'Tap to reset your password',
child: TextButton(
onPressed: _isLoading
? () {}
: _handleResetPassword,
child: const Text('Forgot password?'),
),
),
),
const SizedBox(height: 24),
PrimaryButton(
onPressed: _handleSignIn,
text: _isLoading ? 'Signing in...' : 'Sign In',
isLoading: _isLoading,
),
const SizedBox(height: 16),
Semantics(
button: true,
label: 'Sign up button',
hint: 'Tap to create a new account',
child: TextButton(
onPressed: _isLoading
? () {}
: () => context.push('/sign-up'),
child: const Text(
"Don't have an account? Sign up",
),
),
),
],
),
),
),
),
),
),
),
);
}
}
@@ -0,0 +1,263 @@
// ignore_for_file: deprecated_member_use
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/widgets/app_scaffold.dart';
import '../../../core/widgets/primary_button.dart';
import '../../../core/utils/validators.dart';
import '../application/auth_controller.dart';
class SignUpScreen extends ConsumerStatefulWidget {
const SignUpScreen({super.key});
@override
ConsumerState<SignUpScreen> createState() => _SignUpScreenState();
}
class _SignUpScreenState extends ConsumerState<SignUpScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
final _usernameController = TextEditingController();
bool _isLoading = false;
bool _obscurePassword = true;
bool _obscureConfirmPassword = true;
Future<void> _handleSignUp() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
await ref.read(authControllerProvider.notifier).signUpWithEmail(
_emailController.text.trim(),
_passwordController.text,
_usernameController.text.trim(),
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Sign up failed: $e')),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
_confirmPasswordController.dispose();
_usernameController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AppScaffold(
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 32,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(32),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.06),
blurRadius: 40,
offset: const Offset(0, 24),
),
],
),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'LifeTimer',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
fontWeight: FontWeight.w600,
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.08),
),
),
child: Text(
'Create account',
style: Theme.of(context)
.textTheme
.labelMedium
?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.8),
),
),
),
],
),
const SizedBox(height: 24),
Text(
'Start your journey',
style: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Create an account to begin your 1356-day challenge',
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.7),
),
),
const SizedBox(height: 24),
TextFormField(
controller: _usernameController,
textCapitalization: TextCapitalization.words,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
labelText: 'Username',
prefixIcon: Icon(Icons.person_outline),
),
validator: Validators.validateUsername,
enabled: !_isLoading,
),
const SizedBox(height: 16),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email_outlined),
),
validator: Validators.validateEmail,
enabled: !_isLoading,
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
textInputAction: TextInputAction.next,
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: const Icon(Icons.lock_outlined),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
onPressed: () {
setState(
() => _obscurePassword = !_obscurePassword,
);
},
),
),
validator: Validators.validatePassword,
enabled: !_isLoading,
),
const SizedBox(height: 16),
TextFormField(
controller: _confirmPasswordController,
obscureText: _obscureConfirmPassword,
textInputAction: TextInputAction.done,
decoration: InputDecoration(
labelText: 'Confirm Password',
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(
_obscureConfirmPassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
onPressed: () {
setState(
() => _obscureConfirmPassword =
!_obscureConfirmPassword,
);
},
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please confirm your password';
}
if (value != _passwordController.text) {
return 'Passwords do not match';
}
return null;
},
enabled: !_isLoading,
onFieldSubmitted: (_) => _handleSignUp(),
),
const SizedBox(height: 24),
PrimaryButton(
onPressed: _handleSignUp,
text: _isLoading
? 'Creating account...'
: 'Create Account',
isLoading: _isLoading,
),
const SizedBox(height: 16),
TextButton(
onPressed:
_isLoading ? () {} : () => context.pop(),
child: const Text(
'Already have an account? Sign in',
),
),
],
),
),
),
),
),
),
),
);
}
}
@@ -0,0 +1,105 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../bootstrap/supabase_client.dart';
import '../../../data/models/calendar_entry_model.dart';
import '../../../data/repositories/calendar_repository.dart';
import '../../auth/application/auth_controller.dart';
class CalendarState {
final DateTime selectedDate;
final bool isLoading;
final List<CalendarEntry> entries;
final String? error;
const CalendarState({
required this.selectedDate,
this.isLoading = false,
this.entries = const [],
this.error,
});
CalendarState copyWith({
DateTime? selectedDate,
bool? isLoading,
List<CalendarEntry>? entries,
String? error,
}) {
return CalendarState(
selectedDate: selectedDate ?? this.selectedDate,
isLoading: isLoading ?? this.isLoading,
entries: entries ?? this.entries,
error: error,
);
}
}
class CalendarController extends StateNotifier<CalendarState> {
final CalendarRepository _repository;
final String _userId;
CalendarController(this._repository, this._userId)
: super(CalendarState(selectedDate: DateTime.now())) {
_loadForSelectedDate();
}
Future<void> selectDate(DateTime date) async {
state = state.copyWith(selectedDate: date);
await _loadForSelectedDate();
}
Future<void> refresh() async {
await _loadForSelectedDate();
}
Future<void> addEntry({
required String title,
String? note,
String entryType = 'note',
String? goalId,
}) async {
if (title.trim().isEmpty) return;
try {
final entry = await _repository.addEntry(
userId: _userId,
date: state.selectedDate,
title: title.trim(),
note: note?.trim().isEmpty == true ? null : note?.trim(),
entryType: entryType,
goalId: goalId,
);
final updated = [...state.entries, entry];
state = state.copyWith(entries: updated, error: null);
} catch (e) {
state = state.copyWith(error: e.toString());
}
}
Future<void> _loadForSelectedDate() async {
if (_userId.isEmpty) return;
try {
state = state.copyWith(isLoading: true, error: null);
final entries = await _repository.getEntriesForDate(
userId: _userId,
date: state.selectedDate,
);
state = state.copyWith(isLoading: false, entries: entries);
} catch (e) {
state = state.copyWith(isLoading: false, error: e.toString());
}
}
}
final calendarRepositoryProvider = Provider<CalendarRepository>((ref) {
return CalendarRepository(supabaseClient);
});
final calendarControllerProvider =
StateNotifierProvider<CalendarController, CalendarState>((ref) {
final repo = ref.watch(calendarRepositoryProvider);
final authController = ref.read(authControllerProvider.notifier);
final userId = authController.currentUserId ?? '';
return CalendarController(repo, userId);
});
@@ -0,0 +1,546 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../../core/widgets/app_scaffold.dart';
import '../application/calendar_controller.dart';
import '../../goals/application/goals_controller.dart';
class CalendarScreen extends ConsumerWidget {
final String? initialGoalId;
const CalendarScreen({super.key, this.initialGoalId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(calendarControllerProvider);
final controller = ref.read(calendarControllerProvider.notifier);
final selectedDate = state.selectedDate;
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
final monthText = DateFormat('MMMM').format(selectedDate);
final yearText = DateFormat('yyyy').format(selectedDate);
final dayLabel = DateFormat('EEE, d MMM').format(selectedDate);
return AppScaffold(
title: 'Calendar',
body: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
monthText,
style: textTheme.headlineLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
yearText,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.6),
),
),
const SizedBox(height: 8),
Text(
dayLabel,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.6),
),
),
const SizedBox(height: 24),
_DaySelector(
selectedDate: selectedDate,
onDateSelected: (date) {
controller.selectDate(date);
},
),
const SizedBox(height: 24),
Expanded(
child: _ScheduleList(state: state),
),
],
),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _showAddCalendarEntrySheet(
context,
ref,
state,
initialGoalId: initialGoalId,
),
icon: const Icon(Icons.add),
label: const Text('Add note'),
),
);
}
}
class _DaySelector extends StatelessWidget {
final DateTime selectedDate;
final ValueChanged<DateTime> onDateSelected;
const _DaySelector({
required this.selectedDate,
required this.onDateSelected,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final textTheme = theme.textTheme;
final colorScheme = theme.colorScheme;
// Start of week (Sunday) based on the selected date
final weekdayIndex = selectedDate.weekday % 7;
final weekStart = selectedDate.subtract(Duration(days: weekdayIndex));
final days = List.generate(7, (index) => weekStart.add(Duration(days: index)));
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: List.generate(days.length, (index) {
final date = days[index];
final isSelected = date.year == selectedDate.year &&
date.month == selectedDate.month &&
date.day == selectedDate.day;
final backgroundColor = isSelected
? colorScheme.primary.withValues(alpha: 0.06)
: theme.colorScheme.surface;
final borderColor = isSelected
? colorScheme.primary.withValues(alpha: 0.4)
: Colors.black.withValues(alpha: 0.04);
final label = DateFormat('EEE').format(date);
final dayText = DateFormat('d').format(date);
return Padding(
padding: EdgeInsets.only(right: index == days.length - 1 ? 0 : 12),
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () => onDateSelected(date),
child: Container(
width: 64,
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: borderColor),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: textTheme.labelMedium?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.6),
),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
color: isSelected
? colorScheme.primary
: colorScheme.surface,
borderRadius: BorderRadius.circular(16),
),
child: Text(
dayText,
style: textTheme.titleMedium?.copyWith(
color: isSelected
? colorScheme.onPrimary
: colorScheme.onSurface,
),
),
),
const SizedBox(height: 6),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_Dot(color: colorScheme.primary.withValues(alpha: 0.8)),
const SizedBox(width: 4),
_Dot(color: colorScheme.secondary.withValues(alpha: 0.8)),
const SizedBox(width: 4),
_Dot(color: Theme.of(context).colorScheme.error.withValues(alpha: 0.8)),
],
),
],
),
),
),
);
}),
),
);
}
}
class _Dot extends StatelessWidget {
final Color color;
const _Dot({required this.color});
@override
Widget build(BuildContext context) {
return Container(
width: 4,
height: 4,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
);
}
}
class _ScheduleList extends StatelessWidget {
final CalendarState state;
const _ScheduleList({required this.state});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final entries = state.entries;
if (state.isLoading && entries.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (entries.isEmpty) {
return Center(
child: Text(
'No notes for this day yet.\nAdd a small progress update or reflection.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.6),
),
),
);
}
final items = entries.map((entry) {
final timeOfDay = TimeOfDay.fromDateTime(entry.createdAt);
final formatted = timeOfDay.format(context);
String startTime;
String meridiem;
final parts = formatted.split(' ');
if (parts.length == 2) {
startTime = parts[0];
meridiem = parts[1];
} else {
startTime = formatted;
meridiem = '';
}
String accentLabel;
Color accentColor;
switch (entry.entryType) {
case 'progress':
accentLabel = 'Progress';
accentColor = theme.colorScheme.primary;
break;
case 'milestone':
accentLabel = 'Milestone';
accentColor = theme.colorScheme.secondary;
break;
default:
accentLabel = 'Note';
accentColor = theme.colorScheme.primary;
}
final hasGoal = entry.goalId != null;
return _ScheduleItemData(
startTime: startTime,
meridiem: meridiem,
title: entry.title,
subtitle: entry.note ?? '',
accentLabel: accentLabel,
accentColor: accentColor,
hasGoal: hasGoal,
);
}).toList();
return ListView.separated(
padding: const EdgeInsets.only(bottom: 8),
itemBuilder: (context, index) {
return _ScheduleItem(data: items[index]);
},
separatorBuilder: (context, index) => const SizedBox(height: 16),
itemCount: items.length,
);
}
}
class _ScheduleItemData {
final String startTime;
final String meridiem;
final String title;
final String subtitle;
final String accentLabel;
final Color accentColor;
final bool hasGoal;
_ScheduleItemData({
required this.startTime,
required this.meridiem,
required this.title,
required this.subtitle,
required this.accentLabel,
required this.accentColor,
this.hasGoal = false,
});
}
class _ScheduleItem extends StatelessWidget {
final _ScheduleItemData data;
const _ScheduleItem({required this.data});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final textTheme = theme.textTheme;
final colorScheme = theme.colorScheme;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 72,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
data.startTime,
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 2),
Text(
data.meridiem,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: Colors.black.withValues(alpha: 0.04),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 20,
offset: const Offset(0, 12),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
data.title,
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
),
const SizedBox(width: 12),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
color: data.accentColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
),
child: Text(
data.accentLabel,
style: textTheme.labelSmall?.copyWith(
color: data.accentColor,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 8),
if (data.subtitle.isNotEmpty)
Text(
data.subtitle,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.8),
),
),
if (data.hasGoal) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: colorScheme.secondary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.flag_outlined,
size: 14,
color: colorScheme.secondary,
),
const SizedBox(width: 4),
Text(
'Linked to a goal',
style: textTheme.labelSmall?.copyWith(
color: colorScheme.secondary,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
],
),
),
),
],
);
}
}
Future<void> _showAddCalendarEntrySheet(
BuildContext context,
WidgetRef ref,
CalendarState state, {
String? initialGoalId,
}) async {
final titleController = TextEditingController();
final noteController = TextEditingController();
await showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (sheetContext) {
final bottomInset = MediaQuery.of(sheetContext).viewInsets.bottom;
final goalsState = ref.read(goalsControllerProvider);
String? selectedGoalId = initialGoalId;
return StatefulBuilder(
builder: (context, setModalState) {
return Padding(
padding: EdgeInsets.fromLTRB(24, 16, 24, 16 + bottomInset),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Add note for your day',
style:
Theme.of(sheetContext).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
TextField(
controller: titleController,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
labelText: 'Title',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: noteController,
maxLines: 3,
decoration: const InputDecoration(
labelText: 'Details (optional)',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
if (!goalsState.isLoading && goalsState.goals.isNotEmpty) ...[
DropdownButtonFormField<String>(
initialValue: selectedGoalId,
decoration: const InputDecoration(
labelText: 'Related goal (optional)',
border: OutlineInputBorder(),
),
items: goalsState.goals
.map(
(g) => DropdownMenuItem<String>(
value: g.id,
child: Text(
g.title,
overflow: TextOverflow.ellipsis,
),
),
)
.toList(),
onChanged: (value) {
setModalState(() {
selectedGoalId = value;
});
},
),
const SizedBox(height: 16),
] else ...[
const SizedBox(height: 16),
],
ElevatedButton(
onPressed: () async {
await ref
.read(calendarControllerProvider.notifier)
.addEntry(
title: titleController.text,
note: noteController.text,
goalId: selectedGoalId,
);
if (Navigator.of(sheetContext).canPop()) {
Navigator.of(sheetContext).pop();
}
},
child: const Text('Save to calendar'),
),
],
),
);
},
);
},
);
}
@@ -0,0 +1,165 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'dart:async';
import '../../../data/models/user_model.dart';
import '../../../data/repositories/countdown_repository.dart';
import '../../../bootstrap/supabase_client.dart';
import '../../../core/services/analytics_service.dart';
import '../../../core/utils/date_time_utils.dart';
import '../../../data/services/home_screen_widget_service.dart';
import '../../auth/application/auth_controller.dart';
class CountdownController extends StateNotifier<CountdownState> {
final CountdownRepository _repository;
final String _userId;
final AnalyticsService _analytics = AnalyticsService();
Timer? _timer;
DateTime? _lastUpdateTime;
final HomeScreenWidgetService _widgetService = HomeScreenWidgetService();
CountdownController(this._repository, this._userId) : super(const CountdownState.initial()) {
_loadCountdown();
_startTimer();
}
void _loadCountdown() async {
state = const CountdownState.loading();
try {
final user = await _repository.getCountdownInfo(_userId);
state = CountdownState.loaded(user);
_analytics.logCountdownViewed();
await _updateHomeScreenWidget(user);
} catch (e) {
state = CountdownState.error(e.toString());
_analytics.logError(error: e.toString(), context: 'loadCountdown');
}
}
void loadCountdown() {
_loadCountdown();
}
void _startTimer() {
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (state is CountdownLoaded) {
final loadedState = state as CountdownLoaded;
final now = DateTime.now();
// Only update state if the seconds have actually changed
if (_lastUpdateTime == null ||
_lastUpdateTime!.second != now.second ||
_lastUpdateTime!.minute != now.minute) {
final user = loadedState.user;
final countdownEnd = user?.countdownEndDate;
if (countdownEnd != null) {
final remaining = countdownEnd.difference(now);
if (remaining.isNegative) {
state = CountdownState.completed(user);
_timer?.cancel();
}
}
_lastUpdateTime = now;
}
}
});
}
Future<void> startCountdown() async {
try {
final user = await _repository.startCountdown(_userId);
_analytics.logCountdownStarted(
startDate: user.countdownStartDate!.toIso8601String(),
endDate: user.countdownEndDate!.toIso8601String(),
);
state = CountdownState.loaded(user);
await _updateHomeScreenWidget(user);
} catch (e) {
state = CountdownState.error(e.toString());
_analytics.logError(error: e.toString(), context: 'startCountdown');
}
}
Future<void> _updateHomeScreenWidget(User? user) async {
try {
if (user == null || user.countdownEndDate == null) {
await _widgetService.updateNextCountdownWidget(
title: '1356-day challenge',
timeLeft: 'Not started',
subtitle: 'Open Lifetimer to begin your journey',
);
return;
}
final endDate = user.countdownEndDate!;
final now = DateTime.now();
final remaining = endDate.difference(now);
if (remaining.isNegative) {
await _widgetService.updateNextCountdownWidget(
title: '1356-day challenge',
timeLeft: 'Completed',
subtitle: 'Open Lifetimer to review your journey',
);
return;
}
final compact = DateTimeUtils.formatCountdownCompact(remaining);
final subtitle = 'Ends on ${DateTimeUtils.formatDate(endDate)}';
await _widgetService.updateNextCountdownWidget(
title: '1356-day challenge',
timeLeft: compact,
subtitle: subtitle,
);
} catch (_) {}
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
}
class CountdownState {
final bool isLoading;
final User? user;
final String? error;
const CountdownState({
this.isLoading = false,
this.user,
this.error,
});
const CountdownState.initial() : isLoading = false, user = null, error = null;
const CountdownState.loading() : isLoading = true, user = null, error = null;
const CountdownState.loaded(this.user) : isLoading = false, error = null;
const CountdownState.completed(this.user) : isLoading = false, error = null;
const CountdownState.error(this.error) : isLoading = false, user = null;
}
class CountdownLoaded extends CountdownState {
const CountdownLoaded(User user) : super(user: user);
}
final countdownRepositoryProvider = Provider<CountdownRepository>((ref) {
return CountdownRepository(supabaseClient);
});
final countdownControllerProvider = StateNotifierProvider<CountdownController, CountdownState>((ref) {
final repository = ref.watch(countdownRepositoryProvider);
final authController = ref.read(authControllerProvider.notifier);
final userId = authController.currentUserId ?? '';
if (userId.isEmpty) {
return CountdownController(repository, 'placeholder_user_id');
}
return CountdownController(repository, userId);
});
@@ -0,0 +1,204 @@
// ignore_for_file: deprecated_member_use
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/widgets/app_scaffold.dart';
import '../../../core/widgets/primary_button.dart';
import '../../goals/application/goals_controller.dart';
import '../application/countdown_controller.dart';
import 'countdown_start_confirmation_dialog.dart';
class BucketListConfirmationScreen extends ConsumerWidget {
const BucketListConfirmationScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final goalsState = ref.watch(goalsControllerProvider);
if (goalsState.isLoading) {
return const AppScaffold(
title: 'Confirm Your Bucket List',
body: Center(
child: CircularProgressIndicator(),
),
);
}
if (goalsState.error != null) {
return AppScaffold(
title: 'Confirm Your Bucket List',
body: Center(
child: Text('Error: ${goalsState.error}'),
),
);
}
final goals = goalsState.goals;
if (goals.isEmpty) {
return const AppScaffold(
title: 'Confirm Your Bucket List',
body: Center(
child: Text('No goals in your bucket list'),
),
);
}
return AppScaffold(
title: 'Confirm Your Bucket List',
body: SafeArea(
child: Column(
children: [
Expanded(
child: ListView.builder(
padding: const EdgeInsets.all(16.0),
itemCount: goals.length + 1,
itemBuilder: (context, index) {
if (index == 0) {
return Column(
children: [
Icon(
Icons.checklist_rounded,
size: 64,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 16),
Text(
'Your Bucket List',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'${goals.length} goal${goals.length != 1 ? 's' : ''} ready',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 16),
],
);
}
final goal = goals[index - 1];
return Card(
margin: const EdgeInsets.only(bottom: 12.0),
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,
),
);
},
),
),
Container(
padding: const EdgeInsets.all(16.0),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [
BoxShadow(
color: Theme.of(context).shadowColor.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, -2),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.info_outline,
color: Theme.of(context).colorScheme.onTertiaryContainer,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Review your goals carefully. Once confirmed, you cannot make changes.',
style: TextStyle(
color: Theme.of(context).colorScheme.onTertiaryContainer,
fontSize: 12,
),
),
),
],
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => context.pop(),
child: const Text('Edit Goals'),
),
),
const SizedBox(width: 12),
Expanded(
flex: 2,
child: PrimaryButton(
onPressed: () async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => CountdownStartConfirmationDialog(
goalCount: goals.length,
),
);
if (confirmed == true && context.mounted) {
ref.read(countdownControllerProvider.notifier).loadCountdown();
if (context.mounted) {
context.go('/home');
}
}
},
text: 'Confirm & Start',
),
),
],
),
],
),
),
],
),
),
);
}
}
@@ -0,0 +1,135 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/widgets/primary_button.dart';
import '../application/countdown_controller.dart';
class CountdownStartConfirmationDialog extends ConsumerWidget {
final int goalCount;
const CountdownStartConfirmationDialog({
super.key,
required this.goalCount,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return AlertDialog(
title: const Text('Start Your 1356-Day Journey'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.timer_outlined,
size: 48,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 16),
Text(
'You have $goalCount goal${goalCount != 1 ? 's' : ''} in your bucket list.',
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 12),
Text(
'Once you start the countdown:',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
_buildWarningItem(
context,
Icons.lock_outline,
'The countdown cannot be paused, stopped, or reset',
),
_buildWarningItem(
context,
Icons.edit_off_outlined,
'You will not be able to add, remove, or edit goals',
),
_buildWarningItem(
context,
Icons.timer_off_outlined,
'The 1356 days will run continuously until completion',
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.warning_amber_rounded,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'This action is irreversible. Make sure you are ready to commit.',
style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer,
fontSize: 12,
),
),
),
],
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
PrimaryButton(
onPressed: () async {
try {
await ref.read(countdownControllerProvider.notifier).startCountdown();
if (context.mounted) {
Navigator.of(context).pop(true);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to start countdown: $e')),
);
}
}
},
text: 'Start Countdown',
),
],
);
}
Widget _buildWarningItem(BuildContext context, IconData icon, String text) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
icon,
size: 20,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Expanded(
child: Text(
text,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
],
),
);
}
}
@@ -1,33 +1,123 @@
import 'package:flutter/material.dart';
// ignore_for_file: deprecated_member_use
class HomeCountdownScreen extends StatelessWidget {
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:intl/intl.dart';
import '../../../core/widgets/app_scaffold.dart';
import '../../../core/widgets/primary_button.dart';
import '../../../core/widgets/loading_indicator.dart';
import '../../../data/models/user_model.dart';
import '../application/countdown_controller.dart';
import '../../achievements/application/achievements_controller.dart';
class HomeCountdownScreen extends ConsumerStatefulWidget {
const HomeCountdownScreen({super.key});
@override
ConsumerState<HomeCountdownScreen> createState() => _HomeCountdownScreenState();
}
class _HomeCountdownScreenState extends ConsumerState<HomeCountdownScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('LifeTimer'),
final countdownState = ref.watch(countdownControllerProvider);
final achievementsState = ref.watch(achievementsControllerProvider);
final int? level = achievementsState.totalCount > 0
? achievementsState.level
: null;
return AppScaffold(
body: SafeArea(
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,
),
),
body: const Center(
floatingActionButton: FloatingActionButton(
onPressed: () => context.push('/ai-chat'),
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
child: const Icon(Icons.psychology),
),
);
}
}
class _CountdownNotStartedScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Semantics(
label: 'Countdown not started screen',
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'1356',
style: TextStyle(
fontSize: 72,
fontWeight: FontWeight.bold,
Semantics(
label: 'Timer icon',
child: const Icon(
Icons.timer_outlined,
size: 100,
color: null,
),
),
const SizedBox(height: 32),
Text(
'days remaining',
style: TextStyle(fontSize: 24),
'Ready to Start?',
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
semanticsLabel: 'Ready to Start? Your 1356-day journey is waiting.',
),
SizedBox(height: 32),
const SizedBox(height: 16),
Text(
'Your countdown starts here',
style: TextStyle(fontSize: 18),
'Your 1356-day journey is waiting.\nCreate your bucket list and begin your countdown.',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
Semantics(
button: true,
label: 'Create your goals button',
hint: 'Tap to create your bucket list goals',
child: PrimaryButton(
onPressed: () => context.push('/goals'),
text: 'Create Your Goals',
),
),
const SizedBox(height: 16),
Semantics(
button: true,
label: 'View existing goals button',
hint: 'Tap to view your existing goals',
child: OutlinedButton(
onPressed: () => context.push('/goals'),
child: const Text('View Existing Goals'),
),
),
],
),
@@ -35,3 +125,521 @@ class HomeCountdownScreen extends StatelessWidget {
);
}
}
class _CountdownActiveScreen extends StatelessWidget {
final User user;
final int? level;
const _CountdownActiveScreen({required this.user, this.level});
@override
Widget build(BuildContext context) {
final now = DateTime.now();
final endDate = user.countdownEndDate!;
final remaining = endDate.difference(now);
if (remaining.isNegative) {
return _CountdownCompletedScreen(user: user, level: level);
}
final days = remaining.inDays;
final hours = remaining.inHours % 24;
final minutes = remaining.inMinutes % 60;
final seconds = remaining.inSeconds % 60;
final totalDuration = endDate.difference(user.countdownStartDate!);
final elapsed = now.difference(user.countdownStartDate!);
final progress = elapsed.inSeconds / totalDuration.inSeconds;
return RefreshIndicator(
onRefresh: () async {},
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(height: 32),
Text(
'Your Journey',
style: GoogleFonts.spaceGrotesk(
fontSize: 20,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
color: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.85),
),
),
const SizedBox(height: 8),
Text(
'1356-day challenge',
style: GoogleFonts.plusJakartaSans(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.6),
),
),
if (level != null) ...[
const SizedBox(height: 12),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.primary
.withOpacity(0.08),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.star_rounded,
size: 16,
),
const SizedBox(width: 4),
Text(
'Level $level',
style: Theme.of(context)
.textTheme
.labelMedium
?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
),
],
const SizedBox(height: 16),
_TodayCalendarCard(),
const SizedBox(height: 24),
_CountdownDisplay(
days: days,
hours: hours,
minutes: minutes,
seconds: seconds,
),
const SizedBox(height: 32),
_ProgressRing(progress: progress.clamp(0.0, 1.0)),
const SizedBox(height: 16),
Text(
'${(progress * 100).toStringAsFixed(1)}% Complete',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 48),
_MotivationalMessage(progress: progress),
const SizedBox(height: 32),
PrimaryButton(
onPressed: () => context.push('/goals'),
text: 'View My Goals',
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: () => context.push('/profile'),
icon: const Icon(Icons.person_outline),
label: const Text('My Profile'),
),
],
),
),
),
);
}
}
class _TodayCalendarCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final now = DateTime.now();
final dayLabel = DateFormat('EEE').format(now);
final dateLabel = DateFormat('d MMM').format(now);
return InkWell(
borderRadius: BorderRadius.circular(24),
onTap: () => context.push('/calendar'),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: Colors.black.withOpacity(0.04),
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.03),
blurRadius: 16,
offset: const Offset(0, 8),
),
],
),
child: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.primary
.withOpacity(0.08),
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
dayLabel.toUpperCase(),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
dateLabel,
style: Theme.of(context).textTheme.titleMedium,
),
],
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Today\'s plan',
style:
Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
'Tap to view your calendar',
style:
Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.6),
),
),
],
),
),
const SizedBox(width: 8),
Icon(
Icons.chevron_right,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
),
],
),
),
);
}
}
class _CountdownCompletedScreen extends StatelessWidget {
final User user;
final int? level;
const _CountdownCompletedScreen({required this.user, this.level});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.celebration,
size: 100,
color: Theme.of(context).colorScheme.secondary,
),
const SizedBox(height: 32),
Text(
'Journey Complete!',
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
if (level != null) ...[
const SizedBox(height: 12),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.primary
.withOpacity(0.08),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.star_rounded,
size: 16,
),
const SizedBox(width: 4),
Text(
'Level $level',
style: Theme.of(context)
.textTheme
.labelMedium
?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
),
],
const SizedBox(height: 16),
Text(
'You\'ve completed your 1356-day challenge.\nCongratulations on your achievement!',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
PrimaryButton(
onPressed: () => context.push('/goals'),
text: 'Review Your Journey',
),
],
),
);
}
}
class _CountdownDisplay extends StatelessWidget {
final int days;
final int hours;
final int minutes;
final int seconds;
const _CountdownDisplay({
required this.days,
required this.hours,
required this.minutes,
required this.seconds,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
final heroBackground = isDark ? const Color(0xFF020617) : colorScheme.surface;
final shadowColor = isDark
? Colors.black.withOpacity(0.5)
: Colors.black.withOpacity(0.06);
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 40),
decoration: BoxDecoration(
color: heroBackground,
borderRadius: BorderRadius.circular(40),
boxShadow: [
BoxShadow(
color: shadowColor,
blurRadius: 40,
offset: const Offset(0, 24),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
days.toString(),
style: GoogleFonts.spaceGrotesk(
fontSize: 96,
fontWeight: FontWeight.w700,
letterSpacing: -3,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 8),
Text(
'days remaining',
style: GoogleFonts.plusJakartaSans(
fontSize: 16,
fontWeight: FontWeight.w500,
letterSpacing: 0.3,
color: colorScheme.onSurface.withOpacity(0.7),
),
),
],
),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_TimeUnit(value: hours, label: 'Hours'),
const SizedBox(width: 12),
_TimeUnit(value: minutes, label: 'Minutes'),
const SizedBox(width: 12),
_TimeUnit(value: seconds, label: 'Seconds'),
],
),
],
);
}
}
class _TimeUnit extends StatelessWidget {
final int value;
final String label;
const _TimeUnit({required this.value, required this.label});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
final backgroundColor = isDark
? const Color(0xFF020617)
: const Color(0xFFF3F4F6);
final borderColor = isDark
? Colors.white.withOpacity(0.06)
: Colors.black.withOpacity(0.04);
return Semantics(
label: '$label: $value',
value: value.toString(),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: borderColor),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
value.toString().padLeft(2, '0'),
style: GoogleFonts.spaceGrotesk(
fontSize: 24,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 4),
Text(
label.toUpperCase(),
style: GoogleFonts.plusJakartaSans(
fontSize: 12,
fontWeight: FontWeight.w500,
letterSpacing: 0.3,
color: colorScheme.onSurface.withOpacity(0.7),
),
),
],
),
),
);
}
}
class _ProgressRing extends StatelessWidget {
final double progress;
const _ProgressRing({required this.progress});
@override
Widget build(BuildContext context) {
return Semantics(
label: 'Progress ring',
value: '${(progress * 100).toInt()} percent complete',
child: SizedBox(
width: 200,
height: 200,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(
value: progress,
strokeWidth: 12,
backgroundColor: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3),
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
),
ExcludeSemantics(
child: Text(
'${(progress * 100).toInt()}%',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
],
),
),
);
}
}
class _MotivationalMessage extends StatelessWidget {
final double progress;
const _MotivationalMessage({required this.progress});
@override
Widget build(BuildContext context) {
String message;
if (progress < 0.1) {
message = 'Every great journey begins with a single step. Keep going!';
} else if (progress < 0.25) {
message = 'You\'re building momentum. Stay focused on your goals!';
} else if (progress < 0.5) {
message = 'You\'re making real progress. Halfway there!';
} else if (progress < 0.75) {
message = 'Amazing progress! Your goals are within reach.';
} else if (progress < 0.9) {
message = 'Almost there! Finish strong!';
} else {
message = 'The final stretch. Give it your all!';
}
return Card(
color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
message,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontStyle: FontStyle.italic,
),
textAlign: TextAlign.center,
),
),
);
}
}
@@ -0,0 +1,170 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../data/models/goal_model.dart';
import '../../../data/repositories/goals_repository.dart';
import '../../../bootstrap/supabase_client.dart';
import '../../../core/errors/failure.dart';
import '../../../core/services/analytics_service.dart';
import '../../auth/application/auth_controller.dart';
class GoalsController extends StateNotifier<GoalsState> {
final GoalsRepository _repository;
final String _userId;
final AnalyticsService _analytics = AnalyticsService();
GoalsController(this._repository, this._userId) : super(const GoalsState.initial()) {
loadGoals();
}
Future<void> loadGoals() async {
state = const GoalsState.loading();
try {
final goals = await _repository.getGoals(_userId);
state = GoalsState.loaded(goals);
} catch (e) {
state = GoalsState.error(e.toString());
_analytics.logError(error: e.toString(), context: 'loadGoals');
}
}
Future<void> createGoal(Goal goal) async {
try {
final currentGoalsCount = await _repository.getGoalsCount(_userId);
if (currentGoalsCount >= GoalsRepository.maxGoals) {
throw const ValidationFailure('You can only have up to ${GoalsRepository.maxGoals} goals in your bucket list');
}
await _repository.createGoal(goal);
_analytics.logGoalCreated(
goalId: goal.id,
hasLocation: goal.hasLocation.toString(),
hasImage: goal.hasImage.toString(),
);
await loadGoals();
} on Failure catch (failure) {
state = GoalsState.error(failure.message);
_analytics.logError(error: failure.message, context: 'createGoal');
} catch (e) {
state = GoalsState.error(e.toString());
_analytics.logError(error: e.toString(), context: 'createGoal');
}
}
Future<void> updateGoal(Goal goal) async {
try {
await _repository.updateGoal(goal);
_analytics.logGoalUpdated(goalId: goal.id);
await loadGoals();
} on Failure catch (failure) {
state = GoalsState.error(failure.message);
_analytics.logError(error: failure.message, context: 'updateGoal');
} catch (e) {
state = GoalsState.error(e.toString());
_analytics.logError(error: e.toString(), context: 'updateGoal');
}
}
Future<void> deleteGoal(String goalId) async {
try {
final canModify = await _repository.canModifyGoals(_userId);
if (!canModify) {
throw const ValidationFailure('Cannot delete goals after countdown has started');
}
await _repository.deleteGoal(goalId);
_analytics.logGoalDeleted(goalId: goalId);
await loadGoals();
} on Failure catch (failure) {
state = GoalsState.error(failure.message);
_analytics.logError(error: failure.message, context: 'deleteGoal');
} catch (e) {
state = GoalsState.error(e.toString());
_analytics.logError(error: e.toString(), context: 'deleteGoal');
}
}
Future<void> updateGoalProgress(String goalId, int progress) async {
try {
final goals = state.goals;
final goal = goals.firstWhere((g) => g.id == goalId);
final updatedGoal = goal.copyWith(progress: progress);
await _repository.updateGoal(updatedGoal);
await loadGoals();
} on Failure catch (failure) {
state = GoalsState.error(failure.message);
_analytics.logError(error: failure.message, context: 'updateGoalProgress');
} catch (e) {
state = GoalsState.error(e.toString());
_analytics.logError(error: e.toString(), context: 'updateGoalProgress');
}
}
Future<void> markGoalAsCompleted(String goalId) async {
try {
final goals = state.goals;
final goal = goals.firstWhere((g) => g.id == goalId);
final updatedGoal = goal.copyWith(
progress: 100,
completed: true,
);
await _repository.updateGoal(updatedGoal);
final daysInChallenge = goal.createdAt.difference(DateTime.now()).inDays.abs();
_analytics.logGoalCompleted(
goalId: goalId,
daysInChallenge: daysInChallenge,
);
await loadGoals();
} on Failure catch (failure) {
state = GoalsState.error(failure.message);
_analytics.logError(error: failure.message, context: 'markGoalAsCompleted');
} catch (e) {
state = GoalsState.error(e.toString());
_analytics.logError(error: e.toString(), context: 'markGoalAsCompleted');
}
}
bool get canAddMoreGoals {
return state.goals.length < GoalsRepository.maxGoals;
}
int get remainingGoalsSlots {
return GoalsRepository.maxGoals - state.goals.length;
}
}
class GoalsState {
final bool isLoading;
final List<Goal> goals;
final String? error;
const GoalsState({
this.isLoading = false,
this.goals = const [],
this.error,
});
const GoalsState.initial() : isLoading = false, goals = const [], error = null;
const GoalsState.loading() : isLoading = true, goals = const [], error = null;
const GoalsState.loaded(this.goals) : isLoading = false, error = null;
const GoalsState.error(this.error) : isLoading = false, goals = const [];
}
final goalsRepositoryProvider = Provider<GoalsRepository>((ref) {
return GoalsRepository(supabaseClient);
});
final goalsControllerProvider = StateNotifierProvider<GoalsController, GoalsState>((ref) {
final repository = ref.watch(goalsRepositoryProvider);
final authController = ref.read(authControllerProvider.notifier);
final userId = authController.currentUserId ?? '';
if (userId.isEmpty) {
return GoalsController(repository, 'placeholder_user_id');
}
return GoalsController(repository, userId);
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,227 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/widgets/app_scaffold.dart';
import '../../../core/widgets/primary_button.dart';
import '../../../data/models/goal_model.dart';
import '../application/goals_controller.dart';
class GoalDetailScreen extends ConsumerStatefulWidget {
final String goalId;
const GoalDetailScreen({super.key, required this.goalId});
@override
ConsumerState<GoalDetailScreen> createState() => _GoalDetailScreenState();
}
class _GoalDetailScreenState extends ConsumerState<GoalDetailScreen> {
bool _isLoading = false;
Goal? get goal {
final goalsState = ref.watch(goalsControllerProvider);
return goalsState.goals.firstWhere((g) => g.id == widget.goalId);
}
Future<void> _updateProgress(int progress) async {
setState(() => _isLoading = true);
try {
await ref.read(goalsControllerProvider.notifier).updateGoalProgress(
widget.goalId,
progress,
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error updating progress: $e')),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
Future<void> _markAsCompleted() async {
setState(() => _isLoading = true);
try {
await ref.read(goalsControllerProvider.notifier).markGoalAsCompleted(
widget.goalId,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Goal completed! 🎉')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
@override
Widget build(BuildContext context) {
final goalsState = ref.watch(goalsControllerProvider);
if (goalsState.isLoading) {
return const AppScaffold(
body: Center(child: CircularProgressIndicator()),
);
}
if (goalsState.error != null) {
return AppScaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Error: ${goalsState.error}'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => ref.read(goalsControllerProvider.notifier).loadGoals(),
child: const Text('Retry'),
),
],
),
),
);
}
final currentGoal = goal;
if (currentGoal == null) {
return const AppScaffold(
body: Center(child: Text('Goal not found')),
);
}
return AppScaffold(
title: currentGoal.title,
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (currentGoal.hasImage)
Container(
height: 200,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
image: DecorationImage(
image: NetworkImage(currentGoal.imageUrl!),
fit: BoxFit.cover,
),
),
),
const SizedBox(height: 24),
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Progress',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
LinearProgressIndicator(
value: currentGoal.progress / 100,
minHeight: 8,
),
const SizedBox(height: 8),
Text(
'${currentGoal.progress}% Complete',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
),
const SizedBox(height: 16),
if (currentGoal.description != null)
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Description',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(currentGoal.description!),
],
),
),
),
const SizedBox(height: 16),
if (currentGoal.hasLocation)
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
const Icon(Icons.location_on_outlined),
const SizedBox(width: 8),
Expanded(
child: Text(
currentGoal.locationName ?? 'Location set',
style: Theme.of(context).textTheme.bodyMedium,
),
),
],
),
),
),
const SizedBox(height: 24),
Text(
'Update Progress',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Slider(
value: currentGoal.progress.toDouble(),
min: 0,
max: 100,
divisions: 100,
label: '${currentGoal.progress}%',
onChanged: _isLoading
? null
: (value) => _updateProgress(value.toInt()),
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: () => context.push('/calendar?goalId=${currentGoal.id}'),
icon: const Icon(Icons.calendar_today_outlined),
label: const Text('Add event to calendar'),
),
const SizedBox(height: 24),
if (!currentGoal.completed)
PrimaryButton(
onPressed: _isLoading ? () {} : _markAsCompleted,
text: 'Mark as Completed',
isLoading: _isLoading,
),
const SizedBox(height: 16),
OutlinedButton(
onPressed: () => context.push('/goals/${currentGoal.id}/edit'),
child: const Text('Edit Goal'),
),
],
),
),
),
);
}
}
@@ -0,0 +1,906 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:uuid/uuid.dart';
import 'package:geolocator/geolocator.dart';
import 'package:image_picker/image_picker.dart';
import '../../../bootstrap/env.dart';
import '../../../core/widgets/app_scaffold.dart';
import '../../../core/widgets/primary_button.dart';
import '../../../core/utils/validators.dart';
import '../../../data/models/goal_model.dart';
import '../../../data/models/goal_step_model.dart';
import '../../../data/providers/image_search_provider.dart';
import '../../../data/providers/pexels_image_search_provider.dart';
import '../../../data/services/image_search_service.dart';
import '../../../data/services/pexels_image_search_service.dart';
import '../application/goals_controller.dart';
import 'location_picker_screen.dart';
enum OnlineImageSource { unsplash, pexels }
class LocationData {
final double latitude;
final double longitude;
final String name;
LocationData({
required this.latitude,
required this.longitude,
required this.name,
});
}
class GoalEditScreen extends ConsumerStatefulWidget {
final String? goalId;
const GoalEditScreen({super.key, this.goalId});
@override
ConsumerState<GoalEditScreen> createState() => _GoalEditScreenState();
}
class _GoalEditScreenState extends ConsumerState<GoalEditScreen> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
final _descriptionController = TextEditingController();
final _stepController = TextEditingController();
int _progress = 0;
bool _isLoading = false;
final List<GoalStep> _steps = [];
final Uuid _uuid = const Uuid();
LocationData? _selectedLocation;
bool _isGettingLocation = false;
String? _selectedImagePath;
final ImagePicker _imagePicker = ImagePicker();
List<UnsplashImage> _unsplashResults = [];
List<PexelsImage> _pexelsResults = [];
bool _isSearchingImages = false;
late OnlineImageSource _selectedImageSource;
final TextEditingController _imageSearchController = TextEditingController();
@override
void initState() {
super.initState();
if (Env.unsplashEnabled) {
_selectedImageSource = OnlineImageSource.unsplash;
} else if (Env.pexelsEnabled) {
_selectedImageSource = OnlineImageSource.pexels;
} else {
_selectedImageSource = OnlineImageSource.unsplash;
}
if (widget.goalId != null) {
_loadGoal();
}
}
void _loadGoal() {
final goalsState = ref.read(goalsControllerProvider);
if (goalsState.goals.isNotEmpty) {
final goal = goalsState.goals.firstWhere((g) => g.id == widget.goalId);
_titleController.text = goal.title;
_descriptionController.text = goal.description ?? '';
_progress = goal.progress;
if (goal.hasLocation) {
_selectedLocation = LocationData(
latitude: goal.locationLat!,
longitude: goal.locationLng!,
name: goal.locationName ?? 'Selected Location',
);
}
if (goal.hasImage) {
_selectedImagePath = goal.imageUrl;
}
}
}
Future<void> _pickImage(ImageSource source) async {
try {
final XFile? image = await _imagePicker.pickImage(
source: source,
imageQuality: 80,
maxWidth: 1024,
maxHeight: 1024,
);
if (image != null) {
setState(() {
_selectedImagePath = image.path;
});
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error picking image: $e')),
);
}
}
}
void _showImagePickerDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Select Image'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.camera_alt),
title: const Text('Take Photo'),
onTap: () {
Navigator.pop(context);
_pickImage(ImageSource.camera);
},
),
ListTile(
leading: const Icon(Icons.photo_library),
title: const Text('Choose from Gallery'),
onTap: () {
Navigator.pop(context);
_pickImage(ImageSource.gallery);
},
),
ListTile(
leading: const Icon(Icons.search),
title: const Text('Search Online'),
enabled: Env.unsplashEnabled || Env.pexelsEnabled,
onTap: (Env.unsplashEnabled || Env.pexelsEnabled)
? () {
Navigator.pop(context);
_showImageSearchDialog();
}
: null,
),
],
),
),
);
}
void _showImageSearchDialog() {
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => Dialog(
child: Container(
constraints: const BoxConstraints(maxHeight: 600),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Expanded(
child: TextField(
controller: _imageSearchController,
decoration: const InputDecoration(
hintText: 'Search for images...',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
),
onSubmitted: (query) {
_searchImages(query);
},
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.search),
onPressed: () {
_searchImages(_imageSearchController.text);
},
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Builder(
builder: (context) {
final segments = <ButtonSegment<OnlineImageSource>>[];
if (Env.unsplashEnabled) {
segments.add(const ButtonSegment(
value: OnlineImageSource.unsplash,
label: Text('Unsplash'),
icon: Icon(Icons.photo_library),
));
}
if (Env.pexelsEnabled) {
segments.add(const ButtonSegment(
value: OnlineImageSource.pexels,
label: Text('Pexels'),
icon: Icon(Icons.collections),
));
}
if (segments.isEmpty) {
return const SizedBox.shrink();
}
if (!Env.unsplashEnabled && _selectedImageSource == OnlineImageSource.unsplash && Env.pexelsEnabled) {
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
_selectedImageSource = OnlineImageSource.pexels;
});
});
}
if (!Env.pexelsEnabled && _selectedImageSource == OnlineImageSource.pexels && Env.unsplashEnabled) {
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
_selectedImageSource = OnlineImageSource.unsplash;
});
});
}
return SegmentedButton<OnlineImageSource>(
segments: segments,
selected: {_selectedImageSource},
onSelectionChanged: (Set<OnlineImageSource> newSelection) {
setState(() => _selectedImageSource = newSelection.first);
if (_imageSearchController.text.isNotEmpty) {
_searchImages(_imageSearchController.text);
}
},
);
},
),
),
const Divider(),
Expanded(
child: _isSearchingImages
? const Center(child: CircularProgressIndicator())
: (_unsplashResults.isEmpty && _pexelsResults.isEmpty)
? Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Text(
'Search for images using keywords from your goal title',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
)
: GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: _selectedImageSource == OnlineImageSource.unsplash
? _unsplashResults.length
: _pexelsResults.length,
itemBuilder: (context, index) {
if (_selectedImageSource == OnlineImageSource.unsplash) {
final image = _unsplashResults[index];
return GestureDetector(
onTap: () => _selectUnsplashImage(image),
child: Card(
clipBehavior: Clip.antiAlias,
child: Stack(
fit: StackFit.expand,
children: [
Image.network(
image.url,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: const Icon(Icons.broken_image),
);
},
),
if (image.photographer != null)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withValues(alpha: 0.7),
],
),
),
child: Text(
'Photo by ${image.photographer}',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
],
),
),
);
} else {
final image = _pexelsResults[index];
return GestureDetector(
onTap: () => _selectPexelsImage(image),
child: Card(
clipBehavior: Clip.antiAlias,
child: Stack(
fit: StackFit.expand,
children: [
Image.network(
image.url,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: const Icon(Icons.broken_image),
);
},
),
if (image.photographer != null)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withValues(alpha: 0.7),
],
),
),
child: Text(
'Photo by ${image.photographer}',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
],
),
),
);
}
},
),
),
const Divider(),
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () {
setState(() {
_unsplashResults.clear();
_pexelsResults.clear();
_imageSearchController.clear();
});
Navigator.pop(context);
},
child: const Text('Cancel'),
),
],
),
),
],
),
),
),
),
);
}
void _clearImage() {
setState(() => _selectedImagePath = null);
}
Future<void> _searchImages(String query) async {
if (query.trim().isEmpty) return;
setState(() {
_isSearchingImages = true;
_unsplashResults.clear();
_pexelsResults.clear();
});
try {
if (_selectedImageSource == OnlineImageSource.unsplash) {
final imageSearchService = ref.read(imageSearchServiceProvider);
final results = await imageSearchService.searchImages(
query: query,
perPage: 10,
orientation: 'landscape',
);
setState(() => _unsplashResults = results);
} else {
final pexelsService = ref.read(pexelsImageSearchServiceProvider);
final results = await pexelsService.searchImages(
query: query,
perPage: 10,
orientation: 'landscape',
);
setState(() => _pexelsResults = results);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error searching images: $e')),
);
}
} finally {
setState(() => _isSearchingImages = false);
}
}
void _selectUnsplashImage(UnsplashImage image) {
setState(() {
_selectedImagePath = image.url;
_unsplashResults.clear();
_pexelsResults.clear();
_imageSearchController.clear();
});
if (mounted) {
Navigator.pop(context);
}
}
void _selectPexelsImage(PexelsImage image) {
setState(() {
_selectedImagePath = image.url;
_unsplashResults.clear();
_pexelsResults.clear();
_imageSearchController.clear();
});
if (mounted) {
Navigator.pop(context);
}
}
Future<void> _getCurrentLocation() async {
setState(() => _isGettingLocation = true);
try {
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Location services are disabled')),
);
}
return;
}
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Location permissions are denied')),
);
}
return;
}
}
if (permission == LocationPermission.deniedForever) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Location permissions are permanently denied')),
);
}
return;
}
Position position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
);
setState(() {
_selectedLocation = LocationData(
latitude: position.latitude,
longitude: position.longitude,
name: 'Current Location',
);
});
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error getting location: $e')),
);
}
} finally {
if (mounted) {
setState(() => _isGettingLocation = false);
}
}
}
Future<void> _openLocationPicker() async {
final result = await context.push<LocationPickerResult>('/location-picker');
if (result != null) {
setState(() {
_selectedLocation = LocationData(
latitude: result.position.latitude,
longitude: result.position.longitude,
name: result.address,
);
});
}
}
void _clearLocation() {
setState(() => _selectedLocation = null);
}
void _addStep() {
if (_stepController.text.trim().isEmpty) return;
setState(() {
_steps.add(GoalStep(
id: _uuid.v4(),
goalId: widget.goalId ?? '',
title: _stepController.text.trim(),
isDone: false,
orderIndex: _steps.length,
createdAt: DateTime.now(),
));
_stepController.clear();
});
}
void _removeStep(int index) {
setState(() {
_steps.removeAt(index);
for (int i = 0; i < _steps.length; i++) {
_steps[i] = _steps[i].copyWith(orderIndex: i);
}
});
}
void _toggleStepCompletion(int index) {
setState(() {
_steps[index] = _steps[index].copyWith(isDone: !_steps[index].isDone);
final completedSteps = _steps.where((s) => s.isDone).length;
_progress = _steps.isEmpty ? 0 : ((completedSteps / _steps.length) * 100).round();
});
}
Future<void> _saveGoal() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
final goal = Goal(
id: widget.goalId ?? DateTime.now().millisecondsSinceEpoch.toString(),
ownerId: 'current_user_id',
title: _titleController.text.trim(),
description: _descriptionController.text.trim().isEmpty
? null
: _descriptionController.text.trim(),
progress: _progress,
locationLat: _selectedLocation?.latitude,
locationLng: _selectedLocation?.longitude,
locationName: _selectedLocation?.name,
imageUrl: _selectedImagePath,
completed: _progress == 100,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
if (widget.goalId != null) {
await ref.read(goalsControllerProvider.notifier).updateGoal(goal);
} else {
await ref.read(goalsControllerProvider.notifier).createGoal(goal);
}
if (mounted) {
context.pop();
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error saving goal: $e')),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
@override
void dispose() {
_titleController.dispose();
_descriptionController.dispose();
_stepController.dispose();
_imageSearchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AppScaffold(
title: widget.goalId == null ? 'Create Goal' : 'Edit Goal',
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Semantics(
label: 'Goal title field',
hint: 'Enter your goal title',
child: TextFormField(
controller: _titleController,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
labelText: 'Goal Title *',
hintText: 'e.g., Learn to play guitar',
prefixIcon: Icon(Icons.flag_outlined),
border: OutlineInputBorder(),
),
validator: Validators.validateGoalTitle,
enabled: !_isLoading,
),
),
const SizedBox(height: 16),
Semantics(
label: 'Goal description field',
hint: 'Enter a description for your goal',
child: TextFormField(
controller: _descriptionController,
maxLines: 4,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
labelText: 'Description',
hintText: 'What do you want to achieve?',
prefixIcon: Icon(Icons.description_outlined),
border: OutlineInputBorder(),
),
validator: Validators.validateGoalDescription,
enabled: !_isLoading,
),
),
const SizedBox(height: 24),
Text(
'Cover Image (Optional)',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
if (_selectedImagePath == null)
OutlinedButton.icon(
onPressed: _isLoading ? null : _showImagePickerDialog,
icon: const Icon(Icons.image_outlined),
label: const Text('Add Image'),
)
else
Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(
_selectedImagePath!.startsWith('http')
? File('')
: File(_selectedImagePath!),
width: double.infinity,
height: 200,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
width: double.infinity,
height: 200,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: const Center(
child: Icon(Icons.broken_image),
),
);
},
),
),
Positioned(
top: 8,
right: 8,
child: IconButton(
icon: const Icon(Icons.close, color: Colors.white),
style: IconButton.styleFrom(
backgroundColor: Colors.black54,
),
onPressed: _clearImage,
),
),
],
),
const SizedBox(height: 24),
Text(
'Location (Optional)',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
if (_selectedLocation == null)
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _isGettingLocation ? null : _getCurrentLocation,
icon: _isGettingLocation
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.my_location),
label: const Text('Use Current Location'),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: _isLoading ? null : _openLocationPicker,
icon: const Icon(Icons.map),
label: const Text('Pick on Map'),
),
),
],
)
else
Card(
child: ListTile(
leading: const Icon(Icons.location_on),
title: Text(_selectedLocation!.name),
subtitle: Text(
'${_selectedLocation!.latitude.toStringAsFixed(6)}, ${_selectedLocation!.longitude.toStringAsFixed(6)}',
style: Theme.of(context).textTheme.bodySmall,
),
trailing: IconButton(
icon: const Icon(Icons.clear),
onPressed: _clearLocation,
),
),
),
const SizedBox(height: 24),
Text(
'Progress: $_progress%',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Slider(
value: _progress.toDouble(),
min: 0,
max: 100,
divisions: 100,
label: '$_progress%',
onChanged: (value) {
setState(() => _progress = value.toInt());
},
),
Text(
'Milestones/Steps',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextField(
controller: _stepController,
decoration: const InputDecoration(
labelText: 'Add a step',
hintText: 'e.g., Complete first draft',
prefixIcon: Icon(Icons.add_task_outlined),
border: OutlineInputBorder(),
),
enabled: !_isLoading,
onSubmitted: (_) => _addStep(),
),
),
const SizedBox(width: 8),
IconButton(
onPressed: _isLoading ? null : _addStep,
icon: const Icon(Icons.add_circle),
iconSize: 32,
color: Theme.of(context).colorScheme.primary,
),
],
),
const SizedBox(height: 16),
if (_steps.isEmpty)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
'No steps added yet. Add steps to track your progress.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
)
else
...List.generate(_steps.length, (index) {
final step = _steps[index];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: Checkbox(
value: step.isDone,
onChanged: _isLoading
? null
: (_) => _toggleStepCompletion(index),
),
title: Text(
step.title,
style: TextStyle(
decoration: step.isDone
? TextDecoration.lineThrough
: null,
color: step.isDone
? Theme.of(context).colorScheme.onSurfaceVariant
: null,
),
),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: _isLoading
? null
: () => _removeStep(index),
),
),
);
}),
const SizedBox(height: 24),
const SizedBox(height: 24),
PrimaryButton(
onPressed: _isLoading ? () {} : _saveGoal,
text: _isLoading ? 'Saving...' : 'Save Goal',
isLoading: _isLoading,
),
],
),
),
),
),
);
}
}
@@ -1,17 +1,381 @@
import 'package:flutter/material.dart';
// ignore_for_file: deprecated_member_use
class GoalsListScreen extends StatelessWidget {
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:google_fonts/google_fonts.dart';
import '../../../core/widgets/app_scaffold.dart';
import '../../../core/widgets/empty_state.dart';
import '../../../core/widgets/loading_indicator.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/utils/date_time_utils.dart';
import '../../../data/models/goal_model.dart';
import '../application/goals_controller.dart';
class GoalsListScreen extends ConsumerWidget {
const GoalsListScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Goals'),
Widget build(BuildContext context, WidgetRef ref) {
final goalsState = ref.watch(goalsControllerProvider);
return AppScaffold(
title: 'My Goals',
body: SafeArea(
child: goalsState.isLoading
? const Center(child: LoadingIndicator())
: goalsState.error != null
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Error: ${goalsState.error}'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => ref.read(goalsControllerProvider.notifier).loadGoals(),
child: const Text('Retry'),
),
],
),
)
: goalsState.goals.isEmpty
? EmptyState(
icon: Icons.flag_outlined,
title: 'No goals yet',
subtitle:
'Start by creating your first goal for your 1356-day journey',
actionLabel: 'Add your first goal',
onAction: () => context.push('/goals/create'),
)
: RefreshIndicator(
onRefresh: () =>
ref.read(goalsControllerProvider.notifier).loadGoals(),
child: ListView.builder(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 24,
),
itemCount: goalsState.goals.length,
itemBuilder: (context, index) {
final goal = goalsState.goals[index];
return _GoalCard(goal: goal);
},
),
),
),
body: const Center(
child: Text('Goals List - Coming Soon'),
floatingActionButton: FloatingActionButton(
onPressed: () => context.push('/goals/create'),
child: const Icon(Icons.add),
),
);
}
}
String _progressStageLabel(int progress, bool completed) {
if (completed || progress >= 100) {
return 'Finished';
}
if (progress >= 80) {
return 'Nearly there';
}
if (progress >= 40) {
return 'In progress';
}
if (progress > 0) {
return 'Just beginning';
}
return 'Not started';
}
class _GoalCard extends StatelessWidget {
final Goal goal;
const _GoalCard({required this.goal});
@override
Widget build(BuildContext context) {
final statusLabel =
goal.completed ? 'Completed' : '${goal.progress}% complete';
return Semantics(
button: true,
label: goal.title,
value: statusLabel,
hint: 'Tap to view goal details',
child: Card(
margin: const EdgeInsets.only(bottom: 20),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(32),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => context.push('/goals/${goal.id}'),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_GoalImageHeader(goal: goal),
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (goal.description != null &&
goal.description!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
goal.description!,
style: GoogleFonts.plusJakartaSans(
fontSize: 14,
fontWeight: FontWeight.w400,
color: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.7),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
Semantics(
label: 'Progress: ${goal.progress} percent',
child: ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
value: goal.progress / 100,
minHeight: 8,
backgroundColor: Theme.of(context)
.colorScheme
.primaryContainer,
),
),
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${_progressStageLabel(goal.progress, goal.completed)}${goal.progress}% complete',
style: GoogleFonts.plusJakartaSans(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.75),
),
),
TextButton(
onPressed: () =>
context.push('/goals/${goal.id}'),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 18,
vertical: 8,
),
shape: const StadiumBorder(),
backgroundColor: Theme.of(context)
.colorScheme
.primary
.withOpacity(0.08),
),
child: Text(
'View details',
style: GoogleFonts.spaceGrotesk(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Theme.of(context)
.colorScheme
.primary,
),
),
),
],
),
],
),
),
],
),
),
),
);
}
}
class _GoalImageHeader extends StatelessWidget {
final Goal goal;
const _GoalImageHeader({required this.goal});
@override
Widget build(BuildContext context) {
Widget image;
if (goal.hasImage && goal.imageUrl != null) {
image = CachedNetworkImage(
imageUrl: goal.imageUrl!,
fit: BoxFit.cover,
width: double.infinity,
height: 220,
placeholder: (context, url) => Container(
color: Colors.black12,
),
errorWidget: (context, url, error) => Container(
color: Colors.black12,
alignment: Alignment.center,
child: const Icon(Icons.image_not_supported_outlined),
),
);
} else {
image = Container(
width: double.infinity,
height: 220,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppTheme.pastelBlue,
AppTheme.pastelGreen,
],
),
),
child: Icon(
Icons.flag_rounded,
size: 64,
color: Colors.white.withOpacity(0.9),
),
);
}
return Stack(
children: [
image,
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withOpacity(0.0),
Colors.black.withOpacity(0.65),
],
),
),
),
),
Positioned(
left: 20,
right: 20,
bottom: 20,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Text(
goal.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.spaceGrotesk(
fontSize: 22,
fontWeight: FontWeight.w700,
letterSpacing: 0.1,
color: Colors.white,
),
),
),
if (goal.completed)
Container(
margin: const EdgeInsets.only(left: 12),
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
color: AppTheme.successColor.withOpacity(0.9),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.check_circle,
size: 14,
color: Colors.white,
),
const SizedBox(width: 4),
Text(
'Completed',
style: GoogleFonts.plusJakartaSans(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
if (goal.hasLocation)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.15),
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: Colors.white.withOpacity(0.3),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.location_on_outlined,
size: 14,
color: Colors.white,
),
const SizedBox(width: 4),
Text(
goal.locationName ?? 'Location',
style: GoogleFonts.plusJakartaSans(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
],
),
),
const Spacer(),
Text(
DateTimeUtils.formatShortDate(goal.createdAt),
style: GoogleFonts.plusJakartaSans(
fontSize: 12,
fontWeight: FontWeight.w400,
color: Colors.white.withOpacity(0.9),
),
),
],
),
],
),
),
],
);
}
}
@@ -0,0 +1,201 @@
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:geolocator/geolocator.dart';
class LocationPickerResult {
final LatLng position;
final String address;
LocationPickerResult({
required this.position,
required this.address,
});
}
class LocationPickerScreen extends StatefulWidget {
final LatLng? initialPosition;
const LocationPickerScreen({
super.key,
this.initialPosition,
});
@override
State<LocationPickerScreen> createState() => _LocationPickerScreenState();
}
class _LocationPickerScreenState extends State<LocationPickerScreen> {
late GoogleMapController _mapController;
LatLng _selectedPosition = const LatLng(0, 0);
Set<Marker> _markers = {};
bool _isLoading = true;
final String _selectedAddress = 'Selected Location';
@override
void initState() {
super.initState();
_initializeMap();
}
Future<void> _initializeMap() async {
try {
if (widget.initialPosition != null) {
_selectedPosition = widget.initialPosition!;
} else {
final position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
);
_selectedPosition = LatLng(position.latitude, position.longitude);
}
_updateMarker();
setState(() => _isLoading = false);
} catch (e) {
setState(() => _isLoading = false);
}
}
void _updateMarker() {
setState(() {
_markers = {
Marker(
markerId: const MarkerId('selected_location'),
position: _selectedPosition,
draggable: true,
onDragEnd: (LatLng newPosition) {
setState(() {
_selectedPosition = newPosition;
_markers = {
Marker(
markerId: const MarkerId('selected_location'),
position: newPosition,
draggable: true,
),
};
});
},
),
};
});
}
void _onMapCreated(GoogleMapController controller) {
_mapController = controller;
}
Future<void> _getCurrentLocation() async {
try {
final position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
);
final newLatLng = LatLng(position.latitude, position.longitude);
setState(() => _selectedPosition = newLatLng);
_updateMarker();
_mapController.animateCamera(
CameraUpdate.newLatLngZoom(newLatLng, 15),
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error getting location: $e')),
);
}
}
}
void _confirmLocation() {
Navigator.pop(
context,
LocationPickerResult(
position: _selectedPosition,
address: _selectedAddress,
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Select Location'),
actions: [
IconButton(
icon: const Icon(Icons.my_location),
onPressed: _getCurrentLocation,
tooltip: 'Use current location',
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: Column(
children: [
Expanded(
child: GoogleMap(
onMapCreated: _onMapCreated,
initialCameraPosition: CameraPosition(
target: _selectedPosition,
zoom: 15,
),
markers: _markers,
onTap: (LatLng position) {
setState(() => _selectedPosition = position);
_updateMarker();
},
myLocationEnabled: true,
myLocationButtonEnabled: false,
),
),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 10,
offset: const Offset(0, -2),
),
],
),
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const Icon(Icons.location_on),
const SizedBox(width: 8),
Expanded(
child: Text(
'${_selectedPosition.latitude.toStringAsFixed(6)}, ${_selectedPosition.longitude.toStringAsFixed(6)}',
style: Theme.of(context).textTheme.bodyMedium,
),
),
],
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _confirmLocation,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text('Confirm Location'),
),
),
],
),
),
),
],
),
);
}
}
@@ -0,0 +1,264 @@
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
class OsmLocationPickerResult {
final double latitude;
final double longitude;
final String address;
OsmLocationPickerResult({
required this.latitude,
required this.longitude,
required this.address,
});
}
class OsmLocationPickerScreen extends StatefulWidget {
final double? initialLatitude;
final double? initialLongitude;
const OsmLocationPickerScreen({
super.key,
this.initialLatitude,
this.initialLongitude,
});
@override
State<OsmLocationPickerScreen> createState() => _OsmLocationPickerScreenState();
}
class _OsmLocationPickerScreenState extends State<OsmLocationPickerScreen> {
double _selectedLatitude = 0.0;
double _selectedLongitude = 0.0;
bool _isLoading = true;
final TextEditingController _addressController = TextEditingController();
@override
void initState() {
super.initState();
_initializeLocation();
}
Future<void> _initializeLocation() async {
try {
if (widget.initialLatitude != null && widget.initialLongitude != null) {
_selectedLatitude = widget.initialLatitude!;
_selectedLongitude = widget.initialLongitude!;
} else {
final position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
);
_selectedLatitude = position.latitude;
_selectedLongitude = position.longitude;
}
setState(() => _isLoading = false);
} catch (e) {
setState(() => _isLoading = false);
}
}
Future<void> _getCurrentLocation() async {
try {
final position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
);
setState(() {
_selectedLatitude = position.latitude;
_selectedLongitude = position.longitude;
});
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error getting location: $e')),
);
}
}
}
void _confirmLocation() {
Navigator.pop(
context,
OsmLocationPickerResult(
latitude: _selectedLatitude,
longitude: _selectedLongitude,
address: _addressController.text.isEmpty
? 'Custom Location'
: _addressController.text,
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Select Location'),
actions: [
IconButton(
icon: const Icon(Icons.my_location),
onPressed: _getCurrentLocation,
tooltip: 'Use current location',
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: Column(
children: [
Expanded(
child: Stack(
children: [
Container(
color: Colors.grey[200],
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.map, size: 64, color: Colors.grey),
const SizedBox(height: 16),
Text(
'OpenStreetMap View',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'$_selectedLatitude, $_selectedLongitude',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey[600],
),
),
const SizedBox(height: 16),
const Text(
'Note: Full map integration requires\nGoogle Maps API key.\n'
'You can manually enter coordinates below.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
],
),
),
),
Positioned(
top: 16,
right: 16,
child: FloatingActionButton(
mini: true,
heroTag: 'zoom_in',
onPressed: () {
setState(() {
_selectedLatitude += 0.001;
_selectedLongitude += 0.001;
});
},
child: const Icon(Icons.add),
),
),
Positioned(
top: 72,
right: 16,
child: FloatingActionButton(
mini: true,
heroTag: 'zoom_out',
onPressed: () {
setState(() {
_selectedLatitude -= 0.001;
_selectedLongitude -= 0.001;
});
},
child: const Icon(Icons.remove),
),
),
],
),
),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 10,
offset: const Offset(0, -2),
),
],
),
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _addressController,
decoration: const InputDecoration(
labelText: 'Location Name (Optional)',
prefixIcon: Icon(Icons.location_on),
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextField(
decoration: const InputDecoration(
labelText: 'Latitude',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
controller: TextEditingController(
text: _selectedLatitude.toStringAsFixed(6),
),
onChanged: (value) {
final lat = double.tryParse(value);
if (lat != null) {
setState(() => _selectedLatitude = lat);
}
},
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
decoration: const InputDecoration(
labelText: 'Longitude',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
controller: TextEditingController(
text: _selectedLongitude.toStringAsFixed(6),
),
onChanged: (value) {
final lng = double.tryParse(value);
if (lng != null) {
setState(() => _selectedLongitude = lng);
}
},
),
),
],
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _confirmLocation,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text('Confirm Location'),
),
),
],
),
),
),
],
),
);
}
}
@@ -0,0 +1,63 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_flutter/hive_flutter.dart';
import '../../../core/services/analytics_service.dart';
class OnboardingController extends StateNotifier<bool> {
final AnalyticsService _analytics = AnalyticsService();
static const String _onboardingKey = 'onboarding_completed';
OnboardingController() : super(false) {
_loadOnboardingStatus();
}
Future<void> _loadOnboardingStatus() async {
try {
final box = await Hive.openBox('app_settings');
final completed = box.get(_onboardingKey, defaultValue: false);
state = completed;
} catch (e) {
state = false;
}
}
Future<void> completeOnboarding() async {
try {
final box = await Hive.openBox('app_settings');
await box.put(_onboardingKey, true);
state = true;
_analytics.logOnboardingCompleted();
} catch (e) {
_analytics.logError(error: e.toString(), context: 'completeOnboarding');
}
}
Future<void> skipOnboarding() async {
try {
final box = await Hive.openBox('app_settings');
await box.put(_onboardingKey, true);
state = true;
_analytics.logOnboardingCompleted();
} catch (e) {
_analytics.logError(error: e.toString(), context: 'skipOnboarding');
}
}
void completeStep(String stepName) {
_analytics.logOnboardingStepCompleted(stepName: stepName);
}
Future<void> resetOnboarding() async {
try {
final box = await Hive.openBox('app_settings');
await box.put(_onboardingKey, false);
state = false;
} catch (e) {
_analytics.logError(error: e.toString(), context: 'resetOnboarding');
}
}
}
final onboardingControllerProvider =
StateNotifierProvider<OnboardingController, bool>((ref) {
return OnboardingController();
});
@@ -0,0 +1,161 @@
// ignore_for_file: deprecated_member_use
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/widgets/app_scaffold.dart';
import '../../../core/widgets/primary_button.dart';
import '../application/onboarding_controller.dart';
class OnboardingHowItWorksScreen extends ConsumerWidget {
const OnboardingHowItWorksScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = ref.watch(onboardingControllerProvider.notifier);
return AppScaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 24),
Text(
'How It Works',
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
const _StepCard(
number: 1,
title: 'Create Your Bucket List',
description: 'Add between 1 and 20 goals you want to achieve. Each goal can have a description, location, and image.',
icon: Icons.edit_note,
),
const SizedBox(height: 16),
const _StepCard(
number: 2,
title: 'Finalize Your List',
description: 'Once you\'re happy with your goals, confirm your bucket list. This action cannot be undone.',
icon: Icons.lock,
),
const SizedBox(height: 16),
const _StepCard(
number: 3,
title: 'Start Your 1356-Day Journey',
description: 'The countdown begins immediately. Track your progress and make every day count.',
icon: Icons.timer,
),
const Spacer(),
PrimaryButton(
onPressed: () {
controller.completeStep('how_it_works');
context.push('/onboarding/motivation');
},
text: 'Continue',
),
const SizedBox(height: 16),
],
),
),
),
);
}
}
class _StepCard extends StatelessWidget {
final int number;
final String title;
final String description;
final IconData icon;
const _StepCard({
required this.number,
required this.title,
required this.description,
required this.icon,
});
@override
Widget build(BuildContext context) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Icon(
icon,
color: Theme.of(context).colorScheme.primary,
size: 28,
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: Text(
'$number',
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 8),
Text(
description,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
),
),
],
),
),
],
),
),
);
}
}
@@ -1,36 +1,146 @@
import 'package:flutter/material.dart';
// ignore_for_file: deprecated_member_use
class OnboardingIntroScreen extends StatelessWidget {
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/widgets/app_scaffold.dart';
import '../../../core/widgets/primary_button.dart';
import '../application/onboarding_controller.dart';
class OnboardingIntroScreen extends ConsumerWidget {
const OnboardingIntroScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('LifeTimer'),
),
body: const Padding(
padding: EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Welcome to LifeTimer',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
Widget build(BuildContext context, WidgetRef ref) {
final controller = ref.watch(onboardingControllerProvider.notifier);
return AppScaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 48),
const Icon(
Icons.timer_outlined,
size: 100,
color: null,
),
textAlign: TextAlign.center,
),
SizedBox(height: 16),
Text(
'Your 1356-day journey starts here.\nCreate your bucket list and begin your countdown.',
style: TextStyle(fontSize: 18),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 32),
Text(
'Welcome to LifeTimer',
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
'Your 1356-day journey starts here.\nCreate your bucket list and begin your countdown.',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
height: 1.5,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
const _FeatureCard(
icon: Icons.flag,
title: 'Set Your Goals',
description: 'Create a bucket list of 1-20 meaningful goals',
),
const SizedBox(height: 16),
const _FeatureCard(
icon: Icons.lock_clock,
title: 'Fixed Timeline',
description: '1356 days to achieve everything - no extensions',
),
const SizedBox(height: 16),
const _FeatureCard(
icon: Icons.trending_up,
title: 'Track Progress',
description: 'Watch yourself grow day by day',
),
const Spacer(),
PrimaryButton(
onPressed: () {
controller.completeStep('intro');
context.push('/onboarding/how-it-works');
},
text: 'Get Started',
),
const SizedBox(height: 16),
TextButton(
onPressed: () async {
await controller.skipOnboarding();
if (context.mounted) {
context.push('/home');
}
},
child: const Text('Skip onboarding'),
),
const SizedBox(height: 16),
],
),
),
),
);
}
}
class _FeatureCard extends StatelessWidget {
final IconData icon;
final String title;
final String description;
const _FeatureCard({
required this.icon,
required this.title,
required this.description,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.primary.withOpacity(0.2),
),
),
child: Row(
children: [
Icon(
icon,
color: Theme.of(context).colorScheme.primary,
size: 32,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
description,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
),
),
],
),
),
],
),
);
}
}
@@ -0,0 +1,142 @@
// ignore_for_file: deprecated_member_use
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/widgets/app_scaffold.dart';
import '../../../core/widgets/primary_button.dart';
import '../application/onboarding_controller.dart';
class OnboardingMotivationScreen extends ConsumerWidget {
const OnboardingMotivationScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = ref.watch(onboardingControllerProvider.notifier);
return AppScaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
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,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
'1356 days is approximately 3 years and 8 months.\n\n'
'That\'s enough time to transform your life, learn new skills, '
'build meaningful relationships, and achieve your biggest dreams.\n\n'
'Every day counts. Every step matters. Your journey begins now.',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
height: 1.5,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
const _MotivationCard(
icon: Icons.trending_up,
title: 'Track Progress',
description: 'Watch yourself grow as you complete goals and milestones.',
),
const SizedBox(height: 16),
const _MotivationCard(
icon: Icons.people,
title: 'Join Community',
description: 'Connect with others on similar journeys (optional).',
),
const SizedBox(height: 16),
const _MotivationCard(
icon: Icons.celebration,
title: 'Celebrate Wins',
description: 'Every achievement is worth celebrating.',
),
const Spacer(),
PrimaryButton(
onPressed: () async {
controller.completeStep('motivation');
await controller.completeOnboarding();
if (context.mounted) {
context.push('/profile/create');
}
},
text: 'Get Started',
),
const SizedBox(height: 16),
],
),
),
),
);
}
}
class _MotivationCard extends StatelessWidget {
final IconData icon;
final String title;
final String description;
const _MotivationCard({
required this.icon,
required this.title,
required this.description,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.primary.withOpacity(0.2),
),
),
child: Row(
children: [
Icon(
icon,
color: Theme.of(context).colorScheme.primary,
size: 32,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
description,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
),
),
],
),
),
],
),
);
}
}
@@ -0,0 +1,160 @@
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 '../../../core/errors/failure.dart';
final profileControllerProvider = StateNotifierProvider<ProfileController, ProfileState>((ref) {
final client = supabase.Supabase.instance.client;
final repository = UserRepository(client);
return ProfileController(repository);
});
class ProfileController extends StateNotifier<ProfileState> {
final UserRepository _repository;
ProfileController(this._repository) : super(const ProfileState.initial());
Future<void> loadProfile(String userId) async {
state = const ProfileState.loading();
try {
final user = await _repository.getProfile(userId);
state = ProfileState.loaded(user);
} on Failure catch (failure) {
state = ProfileState.error(failure.message);
} catch (e) {
state = ProfileState.error(e.toString());
}
}
Future<void> updateUsername(String userId, String username) async {
state = const ProfileState.loading();
try {
final isAvailable = await _repository.isUsernameAvailable(username);
if (!isAvailable) {
state = const ProfileState.error('Username is already taken');
return;
}
final updatedUser = await _repository.updateProfile(
userId: userId,
username: username,
);
state = ProfileState.loaded(updatedUser);
} on Failure catch (failure) {
state = ProfileState.error(failure.message);
} catch (e) {
state = ProfileState.error(e.toString());
}
}
Future<void> updateBio(String userId, String bio) async {
state = const ProfileState.loading();
try {
final updatedUser = await _repository.updateProfile(
userId: userId,
bio: bio,
);
state = ProfileState.loaded(updatedUser);
} on Failure catch (failure) {
state = ProfileState.error(failure.message);
} catch (e) {
state = ProfileState.error(e.toString());
}
}
Future<void> updateAvatarUrl(String userId, String avatarUrl) async {
state = const ProfileState.loading();
try {
final updatedUser = await _repository.updateProfile(
userId: userId,
avatarUrl: avatarUrl,
);
state = ProfileState.loaded(updatedUser);
} on Failure catch (failure) {
state = ProfileState.error(failure.message);
} catch (e) {
state = ProfileState.error(e.toString());
}
}
Future<void> toggleProfileVisibility(String userId) async {
final currentState = state;
if (currentState.user == null) return;
state = const ProfileState.loading();
try {
final updatedUser = await _repository.updateProfile(
userId: userId,
isPublicProfile: !currentState.user!.isPublicProfile,
);
state = ProfileState.loaded(updatedUser);
} on Failure catch (failure) {
state = ProfileState.error(failure.message);
} catch (e) {
state = ProfileState.error(e.toString());
}
}
Future<void> completeProfileSetup({
required String userId,
required String username,
String? bio,
String? avatarUrl,
String? twitterHandle,
String? instagramHandle,
String? tiktokHandle,
String? websiteUrl,
}) async {
state = const ProfileState.loading();
try {
final isAvailable = await _repository.isUsernameAvailable(username);
if (!isAvailable) {
state = const ProfileState.error('Username is already taken');
return;
}
final updatedUser = await _repository.updateProfile(
userId: userId,
username: username,
bio: bio,
avatarUrl: avatarUrl,
twitterHandle: twitterHandle,
instagramHandle: instagramHandle,
tiktokHandle: tiktokHandle,
websiteUrl: websiteUrl,
);
state = ProfileState.loaded(updatedUser);
} on Failure catch (failure) {
state = ProfileState.error(failure.message);
} catch (e) {
state = ProfileState.error(e.toString());
}
}
}
class ProfileState {
final bool isLoading;
final app.User? user;
final String? errorMessage;
const ProfileState({
this.isLoading = false,
this.user,
this.errorMessage,
});
const ProfileState.initial() : isLoading = false, user = null, errorMessage = null;
const ProfileState.loading() : isLoading = true, user = null, errorMessage = null;
const ProfileState.loaded(this.user)
: isLoading = false,
errorMessage = null;
const ProfileState.error(this.errorMessage)
: isLoading = false,
user = null;
}
typedef ProfileStateLoaded = ProfileState;
@@ -1,17 +1,537 @@
import 'package:flutter/material.dart';
// ignore_for_file: deprecated_member_use
class ProfileScreen extends StatelessWidget {
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 '../../../core/widgets/app_scaffold.dart';
import '../../../core/widgets/loading_indicator.dart';
import '../../../core/widgets/empty_state.dart';
import '../../../core/utils/date_time_utils.dart';
import '../application/profile_controller.dart';
import '../../achievements/application/achievements_controller.dart';
class ProfileScreen extends ConsumerStatefulWidget {
const ProfileScreen({super.key});
@override
ConsumerState<ProfileScreen> createState() => _ProfileScreenState();
}
class _ProfileScreenState extends ConsumerState<ProfileScreen> {
@override
void initState() {
super.initState();
final userId = supabase.Supabase.instance.client.auth.currentUser?.id;
if (userId != null) {
ref.read(profileControllerProvider.notifier).loadProfile(userId);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Profile'),
final profileState = ref.watch(profileControllerProvider);
final achievementsState = ref.watch(achievementsControllerProvider);
final userId = supabase.Supabase.instance.client.auth.currentUser?.id;
if (userId == null) {
return AppScaffold(
body: Semantics(
label: 'Not signed in',
child: const EmptyState(
icon: Icons.person_off,
title: 'Not Signed In',
subtitle: 'Please sign in to view your profile',
),
),
);
}
if (profileState.isLoading) {
return const AppScaffold(
body: Center(child: LoadingIndicator()),
);
}
if (profileState.errorMessage != null) {
return AppScaffold(
body: Semantics(
label: 'Error loading profile',
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 16),
Text(
'Error loading profile',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(profileState.errorMessage!),
const SizedBox(height: 16),
Semantics(
button: true,
label: 'Retry loading profile',
child: ElevatedButton(
onPressed: () {
ref.read(profileControllerProvider.notifier).loadProfile(userId);
},
child: const Text('Retry'),
),
),
],
),
),
),
);
}
final user = profileState.user;
if (user == null) {
return const AppScaffold(
body: EmptyState(
icon: Icons.person_off,
title: 'Profile Not Found',
subtitle: 'Your profile could not be loaded',
),
);
}
return AppScaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 200,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
background: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Theme.of(context).colorScheme.primary.withOpacity(0.3),
Theme.of(context).colorScheme.surface,
],
),
),
),
),
actions: [
IconButton(
icon: const Icon(Icons.settings),
onPressed: () => context.push('/settings'),
tooltip: 'Settings',
),
],
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Hero(
tag: 'profile-avatar',
child: Container(
width: 100,
height: 100,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(context).colorScheme.primary,
width: 3,
),
),
child: ClipOval(
child: user.avatarUrl != null
? CachedNetworkImage(
imageUrl: user.avatarUrl!,
fit: BoxFit.cover,
placeholder: (context, url) => const Center(
child: CircularProgressIndicator(),
),
errorWidget: (context, url, error) => Icon(
Icons.person,
size: 50,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
)
: Icon(
Icons.person,
size: 50,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
),
),
),
),
const SizedBox(height: 16),
Center(
child: Text(
user.username,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
if (user.bio != null && user.bio!.isNotEmpty) ...[
const SizedBox(height: 8),
Center(
child: Text(
user.bio!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
),
],
const SizedBox(height: 24),
_buildCountdownInfo(context, user),
const SizedBox(height: 24),
_buildStatsSection(context, user, achievementsState.level),
const SizedBox(height: 24),
_buildQuickActions(context),
],
),
),
),
],
),
body: const Center(
child: Text('Profile - Coming Soon'),
);
}
Widget _buildCountdownInfo(BuildContext context, user) {
final hasStarted = user.countdownStartDate != null;
final hasEnded = user.countdownEndDate != null &&
user.countdownEndDate!.isBefore(DateTime.now());
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
hasEnded ? Icons.flag : Icons.timer_outlined,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(
hasEnded ? 'Challenge Complete!' : '1356-Day Challenge',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
if (!hasStarted) ...[
const SizedBox(height: 12),
Text(
'Your challenge hasn\'t started yet. Create your bucket list to begin!',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
] else if (!hasEnded && user.countdownEndDate != null) ...[
const SizedBox(height: 12),
_buildCountdownTimer(context, user.countdownEndDate!),
const SizedBox(height: 8),
Text(
'Started: ${DateTimeUtils.formatDate(user.countdownStartDate!)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
] else ...[
const SizedBox(height: 12),
Text(
'Congratulations! You completed your 1356-day challenge.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
],
],
),
),
);
}
Widget _buildCountdownTimer(BuildContext context, DateTime endDate) {
final remaining = endDate.difference(DateTime.now());
final days = remaining.inDays;
final hours = remaining.inHours % 24;
final minutes = remaining.inMinutes % 60;
final seconds = remaining.inSeconds % 60;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_TimeUnit(label: 'Days', value: days.toString()),
_TimeUnit(label: 'Hours', value: hours.toString().padLeft(2, '0')),
_TimeUnit(label: 'Min', value: minutes.toString().padLeft(2, '0')),
_TimeUnit(label: 'Sec', value: seconds.toString().padLeft(2, '0')),
],
);
}
Widget _buildStatsSection(BuildContext context, user, int? level) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Your Stats',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
if (level != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withOpacity(0.08),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.star_rounded,
size: 16,
),
const SizedBox(width: 4),
Text(
'Level $level',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _StatCard(
icon: Icons.visibility,
label: 'Profile',
value: user.isPublicProfile ? 'Public' : 'Private',
color: user.isPublicProfile ? Colors.green : Colors.orange,
),
),
const SizedBox(width: 12),
Expanded(
child: _StatCard(
icon: Icons.calendar_today,
label: 'Member Since',
value: DateTimeUtils.formatShortDate(user.createdAt),
color: Theme.of(context).colorScheme.primary,
),
),
],
),
],
);
}
Widget _buildQuickActions(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Quick Actions',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
_ActionTile(
icon: Icons.edit,
title: 'Edit Profile',
subtitle: 'Update your avatar, username, or bio',
onTap: () => context.push('/profile/edit'),
),
_ActionTile(
icon: Icons.settings,
title: 'Settings',
subtitle: 'Manage app preferences and privacy',
onTap: () => context.push('/settings'),
),
_ActionTile(
icon: Icons.info_outline,
title: 'About the Challenge',
subtitle: 'Learn more about the 1356-day challenge',
onTap: () => context.push('/settings/about'),
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: () async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Sign Out'),
content: const Text('Are you sure you want to sign out?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
child: const Text('Sign Out'),
),
],
),
);
if (confirmed == true && context.mounted) {
await supabase.Supabase.instance.client.auth.signOut();
if (context.mounted) {
context.go('/');
}
}
},
icon: const Icon(Icons.logout),
label: const Text('Sign Out'),
style: OutlinedButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
minimumSize: const Size(double.infinity, 48),
),
),
],
);
}
}
class _TimeUnit extends StatelessWidget {
final String label;
final String value;
const _TimeUnit({required this.label, required this.value});
@override
Widget build(BuildContext context) {
return Column(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
value,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
const SizedBox(height: 4),
Text(
label,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
);
}
}
class _StatCard extends StatelessWidget {
final IconData icon;
final String label;
final String value;
final Color color;
const _StatCard({
required this.icon,
required this.label,
required this.value,
required this.color,
});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
children: [
Icon(icon, color: color, size: 24),
const SizedBox(height: 8),
Text(
value,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
Text(
label,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
}
class _ActionTile extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
const _ActionTile({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return ListTile(
leading: Icon(icon),
title: Text(title),
subtitle: Text(
subtitle,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
trailing: const Icon(Icons.chevron_right),
onTap: onTap,
);
}
}
@@ -0,0 +1,420 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
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 '../application/profile_controller.dart';
class ProfileSetupScreen extends ConsumerStatefulWidget {
const ProfileSetupScreen({super.key});
@override
ConsumerState<ProfileSetupScreen> createState() => _ProfileSetupScreenState();
}
class _ProfileSetupScreenState extends ConsumerState<ProfileSetupScreen> {
final _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController();
final _bioController = TextEditingController();
final _twitterController = TextEditingController();
final _instagramController = TextEditingController();
final _tiktokController = TextEditingController();
final _websiteController = TextEditingController();
dynamic _avatarFile;
String? _avatarUrl;
bool _isLoading = false;
bool _isCheckingUsername = false;
bool _isUsernameAvailable = true;
String? _usernameError;
final ImagePicker _imagePicker = ImagePicker();
Future<void> _pickAvatar() async {
try {
final XFile? image = await _imagePicker.pickImage(
source: ImageSource.gallery,
maxWidth: 512,
maxHeight: 512,
imageQuality: 85,
);
if (image != null) {
setState(() {
if (kIsWeb) {
_avatarFile = image;
} else {
_avatarFile = File(image.path);
}
});
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to pick image: $e')),
);
}
}
}
Future<String?> _uploadAvatar() async {
if (_avatarFile == null) return null;
try {
final client = supabase.Supabase.instance.client;
final userId = client.auth.currentUser?.id;
if (userId == null) return null;
String fileExt;
String fileName;
String filePath;
if (kIsWeb && _avatarFile is XFile) {
fileExt = (_avatarFile as XFile).path.split('.').last;
fileName = '$userId/avatar.$fileExt';
filePath = 'avatars/$fileName';
final bytes = await (_avatarFile as XFile).readAsBytes();
await client.storage.from('avatars').uploadBinary(
filePath,
bytes,
fileOptions: supabase.FileOptions(contentType: 'image/$fileExt'),
);
} else {
fileExt = (_avatarFile as File).path.split('.').last;
fileName = '$userId/avatar.$fileExt';
filePath = 'avatars/$fileName';
await client.storage.from('avatars').upload(filePath, _avatarFile!);
}
final response = client.storage.from('avatars').getPublicUrl(filePath);
return response;
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to upload avatar: $e')),
);
}
return null;
}
}
Future<void> _checkUsernameAvailability(String username) async {
if (username.length < 3) return;
setState(() {
_isCheckingUsername = true;
_usernameError = null;
});
try {
final client = supabase.Supabase.instance.client;
final response = await client
.from('users')
.select('id')
.eq('username', username)
.maybeSingle();
setState(() {
_isUsernameAvailable = response == null;
_isCheckingUsername = false;
if (!_isUsernameAvailable) {
_usernameError = 'Username is already taken';
}
});
} catch (e) {
setState(() {
_isCheckingUsername = false;
});
}
}
Future<void> _handleCompleteSetup() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
final client = supabase.Supabase.instance.client;
final userId = client.auth.currentUser?.id;
if (userId == null) {
throw Exception('User not authenticated');
}
String? uploadedAvatarUrl;
if (_avatarFile != null) {
uploadedAvatarUrl = await _uploadAvatar();
}
await ref.read(profileControllerProvider.notifier).completeProfileSetup(
userId: userId,
username: _usernameController.text.trim(),
bio: _bioController.text.trim().isEmpty ? null : _bioController.text.trim(),
avatarUrl: uploadedAvatarUrl ?? _avatarUrl,
twitterHandle: _twitterController.text.trim().isEmpty
? null
: _twitterController.text.trim(),
instagramHandle: _instagramController.text.trim().isEmpty
? null
: _instagramController.text.trim(),
tiktokHandle: _tiktokController.text.trim().isEmpty
? null
: _tiktokController.text.trim(),
websiteUrl: _websiteController.text.trim().isEmpty
? null
: _websiteController.text.trim(),
);
if (mounted) {
context.go('/onboarding');
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to complete setup: $e')),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
@override
void dispose() {
_usernameController.dispose();
_bioController.dispose();
_twitterController.dispose();
_instagramController.dispose();
_tiktokController.dispose();
_websiteController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AppScaffold(
title: 'Complete Your Profile',
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 16),
Center(
child: GestureDetector(
onTap: _isLoading ? null : _pickAvatar,
child: Stack(
children: [
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
border: Border.all(
color: Theme.of(context).colorScheme.outline,
width: 2,
),
),
child: _avatarFile != null
? ClipOval(
child: Image.file(
_avatarFile!,
fit: BoxFit.cover,
),
)
: _avatarUrl != null
? ClipOval(
child: Image.network(
_avatarUrl!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Icon(
Icons.person,
size: 60,
color: Theme.of(context).colorScheme.onSurfaceVariant,
);
},
),
)
: Icon(
Icons.person,
size: 60,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
if (!_isLoading)
Positioned(
bottom: 0,
right: 0,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Icon(
Icons.camera_alt,
size: 20,
color: Theme.of(context).colorScheme.onPrimary,
),
),
),
),
],
),
),
),
const SizedBox(height: 8),
Center(
child: Text(
'Tap to add a photo',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(height: 32),
TextFormField(
controller: _usernameController,
textCapitalization: TextCapitalization.none,
decoration: InputDecoration(
labelText: 'Username',
prefixIcon: const Icon(Icons.alternate_email),
suffixIcon: _isCheckingUsername
? const SizedBox(
width: 20,
height: 20,
child: Padding(
padding: EdgeInsets.all(12.0),
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: _usernameController.text.isNotEmpty && !_isCheckingUsername
? Icon(
_isUsernameAvailable ? Icons.check_circle : Icons.cancel,
color: _isUsernameAvailable ? Colors.green : Colors.red,
)
: null,
border: const OutlineInputBorder(),
),
validator: Validators.validateUsername,
enabled: !_isLoading,
onChanged: (value) {
if (value.length >= 3) {
_checkUsernameAvailability(value.trim());
}
},
),
if (_usernameError != null) ...[
const SizedBox(height: 4),
Text(
_usernameError!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
),
],
const SizedBox(height: 16),
TextFormField(
controller: _bioController,
maxLines: 3,
maxLength: 150,
decoration: const InputDecoration(
labelText: 'Bio (optional)',
prefixIcon: Icon(Icons.info_outline),
border: OutlineInputBorder(),
helperText: 'Tell others a bit about yourself',
),
enabled: !_isLoading,
),
const SizedBox(height: 16),
TextFormField(
controller: _twitterController,
decoration: const InputDecoration(
labelText: 'Twitter (optional)',
prefixIcon: Icon(Icons.alternate_email),
border: OutlineInputBorder(),
),
enabled: !_isLoading,
),
const SizedBox(height: 12),
TextFormField(
controller: _instagramController,
decoration: const InputDecoration(
labelText: 'Instagram (optional)',
prefixIcon: Icon(Icons.camera_alt_outlined),
border: OutlineInputBorder(),
),
enabled: !_isLoading,
),
const SizedBox(height: 12),
TextFormField(
controller: _tiktokController,
decoration: const InputDecoration(
labelText: 'TikTok (optional)',
prefixIcon: Icon(Icons.music_note_outlined),
border: OutlineInputBorder(),
),
enabled: !_isLoading,
),
const SizedBox(height: 12),
TextFormField(
controller: _websiteController,
decoration: const InputDecoration(
labelText: 'Website (optional)',
prefixIcon: Icon(Icons.link),
border: OutlineInputBorder(),
),
enabled: !_isLoading,
),
const SizedBox(height: 32),
PrimaryButton(
onPressed: () {
if (!_isLoading) {
_handleCompleteSetup();
}
},
text: _isLoading ? 'Saving...' : 'Continue',
isLoading: _isLoading,
),
const SizedBox(height: 16),
TextButton(
onPressed: _isLoading
? null
: () async {
try {
await supabase.Supabase.instance.client.auth.signOut();
if (!context.mounted) return;
context.go('/');
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Sign out failed: $e')),
);
}
},
child: const Text('Sign out'),
),
],
),
),
),
),
);
}
}
@@ -0,0 +1,101 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../data/repositories/user_repository.dart';
import '../../../data/repositories/notifications_repository.dart';
import '../../../bootstrap/supabase_client.dart';
import '../../auth/application/auth_controller.dart';
final userRepositoryProvider = Provider<UserRepository>((ref) {
return UserRepository(supabaseClient);
});
final notificationsRepositoryProvider = Provider<NotificationsRepository>((ref) {
return NotificationsRepository();
});
class SettingsState {
final bool isLoading;
final String? error;
final bool notificationsEnabled;
const SettingsState({
this.isLoading = false,
this.error,
this.notificationsEnabled = true,
});
SettingsState copyWith({
bool? isLoading,
String? error,
bool? notificationsEnabled,
}) {
return SettingsState(
isLoading: isLoading ?? this.isLoading,
error: error,
notificationsEnabled: notificationsEnabled ?? this.notificationsEnabled,
);
}
}
class SettingsController extends StateNotifier<SettingsState> {
final UserRepository _userRepository;
final NotificationsRepository _notificationsRepository;
final AuthController _authController;
SettingsController(
this._userRepository,
this._notificationsRepository,
this._authController,
) : super(const SettingsState());
Future<void> toggleNotifications(bool enabled) async {
state = state.copyWith(isLoading: true);
try {
state = state.copyWith(
isLoading: false,
notificationsEnabled: enabled,
);
if (!enabled) {
await _notificationsRepository.cancelAllNotifications();
}
} catch (e) {
state = state.copyWith(
isLoading: false,
error: e.toString(),
);
}
}
Future<void> deleteAccount() async {
state = state.copyWith(isLoading: true);
try {
final userId = _authController.currentUserId;
if (userId != null) {
await _userRepository.deleteAccount(userId);
await _authController.signOut();
state = const SettingsState();
}
} catch (e) {
state = state.copyWith(
isLoading: false,
error: e.toString(),
);
}
}
void clearError() {
state = state.copyWith(error: null);
}
}
final settingsControllerProvider =
StateNotifierProvider<SettingsController, SettingsState>((ref) {
final userRepository = ref.watch(userRepositoryProvider);
final notificationsRepository = ref.watch(notificationsRepositoryProvider);
final authController = ref.watch(authControllerProvider.notifier);
return SettingsController(
userRepository,
notificationsRepository,
authController,
);
});
@@ -0,0 +1,216 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../core/widgets/app_scaffold.dart';
class AboutChallengeScreen extends StatelessWidget {
const AboutChallengeScreen({super.key});
@override
Widget build(BuildContext context) {
return AppScaffold(
body: ListView(
padding: const EdgeInsets.all(16.0),
children: [
const SizedBox(height: 16),
Center(
child: Icon(
Icons.timer_outlined,
size: 80,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 24),
Center(
child: Text(
'The 1356-Day Challenge',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 16),
_buildSection(
context,
title: 'What is it?',
content: 'The 1356-Day Challenge is a personal commitment to achieve your goals within exactly 1356 days (approximately 3 years, 8 months, and 11 days). Once you start your countdown, there is no stopping, pausing, or extending it.',
),
_buildSection(
context,
title: 'How it works',
content: '1. Create your bucket list with 1-20 goals\n'
'2. Add milestones and track your progress\n'
'3. Finalize your list to start the countdown\n'
'4. Work towards completing your goals before time runs out',
),
_buildSection(
context,
title: 'The Rules',
content: '• You can create between 1 and 20 goals\n'
'• The countdown only starts after you finalize your list\n'
'• Once started, the countdown cannot be paused or reset\n'
'• You can track progress but cannot change the duration\n'
'• After 1356 days, the challenge ends',
),
_buildSection(
context,
title: 'Why 1356 days?',
content: '1356 days represents approximately 3.7 years - a meaningful timeframe that\'s long enough to achieve significant life goals but short enough to maintain urgency and motivation. It\'s the perfect balance between ambition and achievability.',
),
_buildSection(
context,
title: 'Tips for Success',
content: '• Choose goals that truly matter to you\n'
'• Break large goals into smaller milestones\n'
'• Update your progress regularly\n'
'• Stay motivated by tracking your achievements\n'
'• Share your journey with others (optional)',
),
_buildSection(
context,
title: 'Privacy',
content: 'Your goals and progress are private by default. You can choose to make your profile public to share your achievements with the community, but your detailed goal information remains private.',
),
const SizedBox(height: 24),
Center(
child: Text(
'About Project 1356',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 16),
_buildSection(
context,
title: 'Origin of Project 1356',
content:
'Project 1356 began in April 2022 as a mysterious social media countdown created by Armin Mehdizadeh. Every day, he posted a short video erasing a number on a whiteboard and writing the next lower number, counting down from 1,356 to zero. The countdown was set to end on January 1, 2026, exactly 1,356 days after it began - a number chosen after asking Google how many days were left until that date.',
),
_buildSection(
context,
title: 'The Reveal',
content:
'On January 1, 2026, Armin revealed that Project 1356 was a personal challenge: achieve six major life goals in 1,356 days or face public embarrassment. The goals included reaching 100,000 YouTube subscribers, earning \$10,000 per month, building a business that makes \$10,000 monthly, reaching a weight of 185 pounds, earning a business administration degree, and becoming a skilled music producer. In the end, he achieved two of the goals related to income and business revenue but did not complete the others.',
),
_buildSection(
context,
title: 'More Than One Person\'s Story',
content:
'Over time, the project grew far beyond Armin\'s personal journey. Thousands of followers started using the countdown idea to track their own milestones - quitting alcohol, improving fitness, getting married, changing careers, and more. The real value was not just reaching zero, but the transformation that happened during the 1,356 days.',
),
_buildSection(
context,
title: 'Project 1356 - Part 2',
content:
'On January 7, 2026, Armin announced Project 1356 Part 2. Instead of everyone watching his countdown, he invited people to set their own six life-changing goals for the next 1,356 days. Participants do not have to reveal their goals publicly, but they commit to the long-term journey of accountability and growth.',
),
Card(
margin: const EdgeInsets.only(bottom: 16),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Follow Project 1356 & Armin',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 12),
Text(
'To follow the creator and the ongoing community around Project 1356:',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
height: 1.5,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton.icon(
onPressed: () => _openLink(
context,
'https://www.instagram.com/project.1356/',
),
icon: const Icon(Icons.camera_alt_outlined),
label: const Text('Instagram'),
),
OutlinedButton.icon(
onPressed: () => _openLink(
context,
'https://www.youtube.com/@arminmehdiz',
),
icon: const Icon(Icons.ondemand_video_outlined),
label: const Text('YouTube'),
),
],
),
],
),
),
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () => context.pop(),
icon: const Icon(Icons.check),
label: const Text('Got it!'),
),
const SizedBox(height: 16),
],
),
);
}
Widget _buildSection(BuildContext context, {
required String title,
required String content,
}) {
return Card(
margin: const EdgeInsets.only(bottom: 16),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 12),
Text(
content,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
height: 1.5,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
Future<void> _openLink(BuildContext context, String url) async {
final uri = Uri.parse(url);
final launched = await launchUrl(
uri,
mode: LaunchMode.externalApplication,
);
if (!launched) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not open link')),
);
}
}
}
@@ -0,0 +1,206 @@
// ignore_for_file: deprecated_member_use
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../core/widgets/app_scaffold.dart';
enum ThemeMode { light, dark, system }
enum TimeFormat { twelveHour, twentyFourHour }
class AppearanceSettingsScreen extends ConsumerStatefulWidget {
const AppearanceSettingsScreen({super.key});
@override
ConsumerState<AppearanceSettingsScreen> createState() => _AppearanceSettingsScreenState();
}
class _AppearanceSettingsScreenState extends ConsumerState<AppearanceSettingsScreen> {
ThemeMode _themeMode = ThemeMode.system;
TimeFormat _timeFormat = TimeFormat.twelveHour;
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadPreferences();
}
Future<void> _loadPreferences() async {
final prefs = await SharedPreferences.getInstance();
final themeModeIndex = prefs.getInt('theme_mode') ?? 2;
final timeFormatIndex = prefs.getInt('time_format') ?? 0;
setState(() {
_themeMode = ThemeMode.values[themeModeIndex];
_timeFormat = TimeFormat.values[timeFormatIndex];
_isLoading = false;
});
}
Future<void> _saveThemeMode(ThemeMode mode) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('theme_mode', mode.index);
setState(() => _themeMode = mode);
}
Future<void> _saveTimeFormat(TimeFormat format) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('time_format', format.index);
setState(() => _timeFormat = format);
}
@override
Widget build(BuildContext context) {
return AppScaffold(
title: 'Appearance',
body: _isLoading
? const Center(child: CircularProgressIndicator())
: ListView(
children: [
_buildSection(
context,
title: 'Theme',
children: [
RadioListTile<ThemeMode>(
title: const Text('Light'),
subtitle: const Text('Always use light theme'),
value: ThemeMode.light,
groupValue: _themeMode,
onChanged: (value) {
if (value != null) {
_saveThemeMode(value);
}
},
),
RadioListTile<ThemeMode>(
title: const Text('Dark'),
subtitle: const Text('Always use dark theme'),
value: ThemeMode.dark,
groupValue: _themeMode,
onChanged: (value) {
if (value != null) {
_saveThemeMode(value);
}
},
),
RadioListTile<ThemeMode>(
title: const Text('System Default'),
subtitle: const Text('Follow device theme settings'),
value: ThemeMode.system,
groupValue: _themeMode,
onChanged: (value) {
if (value != null) {
_saveThemeMode(value);
}
},
),
],
),
_buildSection(
context,
title: 'Time Format',
children: [
RadioListTile<TimeFormat>(
title: const Text('12-hour'),
subtitle: const Text('e.g., 3:30 PM'),
value: TimeFormat.twelveHour,
groupValue: _timeFormat,
onChanged: (value) {
if (value != null) {
_saveTimeFormat(value);
}
},
),
RadioListTile<TimeFormat>(
title: const Text('24-hour'),
subtitle: const Text('e.g., 15:30'),
value: TimeFormat.twentyFourHour,
groupValue: _timeFormat,
onChanged: (value) {
if (value != null) {
_saveTimeFormat(value);
}
},
),
],
),
_buildSection(
context,
title: 'Preview',
children: [
Card(
margin: const EdgeInsets.all(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Countdown Preview',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
_formatTimePreview(),
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 8),
Text(
'Days remaining in your challenge',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
),
],
),
const SizedBox(height: 32),
],
),
);
}
String _formatTimePreview() {
final now = DateTime.now();
final hours = now.hour;
final minutes = now.minute.toString().padLeft(2, '0');
if (_timeFormat == TimeFormat.twentyFourHour) {
return '$hours:$minutes';
} else {
final period = hours >= 12 ? 'PM' : 'AM';
final displayHours = hours > 12 ? hours - 12 : (hours == 0 ? 12 : hours);
return '$displayHours:$minutes $period';
}
}
Widget _buildSection(BuildContext context, {
required String title,
required List<Widget> children,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
...children,
],
);
}
}
@@ -0,0 +1,264 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/widgets/app_scaffold.dart';
final notificationSettingsProvider = StateNotifierProvider<NotificationSettingsController, NotificationSettings>((ref) {
return NotificationSettingsController();
});
class NotificationSettingsController extends StateNotifier<NotificationSettings> {
NotificationSettingsController() : super(const NotificationSettings());
void updateCountdownReminder(Frequency frequency) {
state = state.copyWith(countdownReminderFrequency: frequency);
}
void updateGoalProgress(bool enabled) {
state = state.copyWith(goalProgressNotifications: enabled);
}
void updateMilestoneAlerts(bool enabled) {
state = state.copyWith(milestoneAlerts: enabled);
}
void updateCountdownCheckpoints(bool enabled) {
state = state.copyWith(countdownCheckpoints: enabled);
}
}
class NotificationSettings {
final Frequency countdownReminderFrequency;
final bool goalProgressNotifications;
final bool milestoneAlerts;
final bool countdownCheckpoints;
const NotificationSettings({
this.countdownReminderFrequency = Frequency.daily,
this.goalProgressNotifications = true,
this.milestoneAlerts = true,
this.countdownCheckpoints = true,
});
NotificationSettings copyWith({
Frequency? countdownReminderFrequency,
bool? goalProgressNotifications,
bool? milestoneAlerts,
bool? countdownCheckpoints,
}) {
return NotificationSettings(
countdownReminderFrequency: countdownReminderFrequency ?? this.countdownReminderFrequency,
goalProgressNotifications: goalProgressNotifications ?? this.goalProgressNotifications,
milestoneAlerts: milestoneAlerts ?? this.milestoneAlerts,
countdownCheckpoints: countdownCheckpoints ?? this.countdownCheckpoints,
);
}
}
enum Frequency {
never,
daily,
weekly,
custom,
}
extension FrequencyExtension on Frequency {
String get label {
switch (this) {
case Frequency.never:
return 'Never';
case Frequency.daily:
return 'Daily';
case Frequency.weekly:
return 'Weekly';
case Frequency.custom:
return 'Custom';
}
}
String get description {
switch (this) {
case Frequency.never:
return 'No reminders';
case Frequency.daily:
return 'Receive daily countdown reminders';
case Frequency.weekly:
return 'Receive weekly countdown reminders';
case Frequency.custom:
return 'Set custom reminder schedule';
}
}
}
class NotificationSettingsScreen extends ConsumerWidget {
const NotificationSettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(notificationSettingsProvider);
return AppScaffold(
body: ListView(
children: [
_buildSection(
context,
title: 'Countdown Reminders',
children: [
_FrequencyTile(
title: 'Reminder Frequency',
subtitle: settings.countdownReminderFrequency.description,
currentFrequency: settings.countdownReminderFrequency,
onChanged: (frequency) {
ref.read(notificationSettingsProvider.notifier).updateCountdownReminder(frequency);
},
),
],
),
_buildSection(
context,
title: 'Goal Notifications',
children: [
_SwitchTile(
title: 'Goal Progress',
subtitle: 'Get notified about goal updates',
value: settings.goalProgressNotifications,
onChanged: (value) {
ref.read(notificationSettingsProvider.notifier).updateGoalProgress(value);
},
),
_SwitchTile(
title: 'Milestone Alerts',
subtitle: 'Celebrate when you complete milestones',
value: settings.milestoneAlerts,
onChanged: (value) {
ref.read(notificationSettingsProvider.notifier).updateMilestoneAlerts(value);
},
),
],
),
_buildSection(
context,
title: 'Countdown Checkpoints',
children: [
_SwitchTile(
title: 'Checkpoint Notifications',
subtitle: 'Get notified at 50%, 25%, and 10% remaining',
value: settings.countdownCheckpoints,
onChanged: (value) {
ref.read(notificationSettingsProvider.notifier).updateCountdownCheckpoints(value);
},
),
],
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: FilledButton.icon(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Notification preferences saved')),
);
context.pop();
},
icon: const Icon(Icons.save),
label: const Text('Save Preferences'),
),
),
const SizedBox(height: 16),
],
),
);
}
Widget _buildSection(BuildContext context, {
required String title,
required List<Widget> children,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
...children,
],
);
}
}
class _SwitchTile extends StatelessWidget {
final String title;
final String subtitle;
final bool value;
final ValueChanged<bool> onChanged;
const _SwitchTile({
required this.title,
required this.subtitle,
required this.value,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return SwitchListTile(
title: Text(title),
subtitle: Text(
subtitle,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
value: value,
onChanged: onChanged,
);
}
}
class _FrequencyTile extends StatelessWidget {
final String title;
final String subtitle;
final Frequency currentFrequency;
final ValueChanged<Frequency> onChanged;
const _FrequencyTile({
required this.title,
required this.subtitle,
required this.currentFrequency,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(title),
subtitle: Text(
subtitle,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
trailing: DropdownButton<Frequency>(
value: currentFrequency,
items: Frequency.values.map((frequency) {
return DropdownMenuItem<Frequency>(
value: frequency,
child: Text(frequency.label),
);
}).toList(),
onChanged: (value) {
if (value != null) {
onChanged(value);
}
},
),
);
}
}
@@ -0,0 +1,307 @@
// ignore_for_file: deprecated_member_use
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/widgets/app_scaffold.dart';
import '../../profile/application/profile_controller.dart';
class PrivacySettingsScreen extends ConsumerStatefulWidget {
const PrivacySettingsScreen({super.key});
@override
ConsumerState<PrivacySettingsScreen> createState() => _PrivacySettingsScreenState();
}
class _PrivacySettingsScreenState extends ConsumerState<PrivacySettingsScreen> {
bool _isLoading = false;
@override
Widget build(BuildContext context) {
final profileState = ref.watch(profileControllerProvider);
final user = profileState.user;
return AppScaffold(
body: ListView(
children: [
_buildSection(
context,
title: 'Profile Visibility',
children: [
if (user != null)
_VisibilityTile(
title: 'Make Profile Public',
subtitle: user.isPublicProfile
? 'Your profile is visible to other users'
: 'Your profile is private and only visible to you',
isPublic: user.isPublicProfile,
onChanged: _isLoading
? null
: (value) => _toggleProfileVisibility(value, user.id),
),
const _InfoTile(
icon: Icons.info_outline,
title: 'What does public mean?',
description: 'When your profile is public, other users can see your username, avatar, and high-level stats. Your goals and detailed progress remain private.',
),
],
),
_buildSection(
context,
title: 'Data & Privacy',
children: [
_SettingsTile(
icon: Icons.download,
title: 'Export My Data',
subtitle: 'Download a copy of your personal data',
onTap: () => _showExportDataDialog(context),
),
_SettingsTile(
icon: Icons.block,
title: 'Blocked Users',
subtitle: 'Manage users you have blocked',
onTap: () => context.push('/settings/privacy/blocked'),
),
],
),
_buildSection(
context,
title: 'Account Control',
children: [
_SettingsTile(
icon: Icons.delete_forever,
title: 'Delete Account',
subtitle: 'Permanently delete your account and all data',
onTap: () => _showDeleteAccountDialog(context),
isDestructive: true,
),
],
),
const SizedBox(height: 24),
],
),
);
}
Widget _buildSection(BuildContext context, {
required String title,
required List<Widget> children,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
...children,
],
);
}
Future<void> _toggleProfileVisibility(bool isPublic, String userId) async {
setState(() => _isLoading = true);
try {
await ref.read(profileControllerProvider.notifier).toggleProfileVisibility(userId);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(isPublic
? 'Your profile is now public'
: 'Your profile is now private'),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to update visibility: $e')),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
void _showExportDataDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Export My Data'),
content: const Text(
'We will prepare a downloadable file containing your profile information, goals, and progress. This may take a few moments.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Data export request submitted. You will receive an email when ready.'),
),
);
},
child: const Text('Request Export'),
),
],
),
);
}
void _showDeleteAccountDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Account'),
content: const Text(
'Are you sure you want to delete your account? This action cannot be undone and all your data will be permanently lost.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Account deletion requires email confirmation'),
),
);
},
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
child: const Text('Delete'),
),
],
),
);
}
}
class _VisibilityTile extends StatelessWidget {
final String title;
final String subtitle;
final bool isPublic;
final ValueChanged<bool>? onChanged;
const _VisibilityTile({
required this.title,
required this.subtitle,
required this.isPublic,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return SwitchListTile(
title: Text(title),
subtitle: Text(
subtitle,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
value: isPublic,
onChanged: onChanged,
secondary: Icon(
isPublic ? Icons.public : Icons.lock,
color: isPublic
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurfaceVariant,
),
);
}
}
class _InfoTile extends StatelessWidget {
final IconData icon;
final String title;
final String description;
const _InfoTile({
required this.icon,
required this.title,
required this.description,
});
@override
Widget build(BuildContext context) {
return ListTile(
leading: Icon(
icon,
color: Theme.of(context).colorScheme.primary.withOpacity(0.7),
),
title: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
),
),
subtitle: Text(
description,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
);
}
}
class _SettingsTile extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
final bool isDestructive;
const _SettingsTile({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
this.isDestructive = false,
});
@override
Widget build(BuildContext context) {
return ListTile(
leading: Icon(
icon,
color: isDestructive
? Theme.of(context).colorScheme.error
: Theme.of(context).colorScheme.primary,
),
title: Text(
title,
style: TextStyle(
color: isDestructive
? Theme.of(context).colorScheme.error
: null,
),
),
subtitle: Text(
subtitle,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
trailing: const Icon(Icons.chevron_right),
onTap: onTap,
);
}
}
@@ -1,16 +1,305 @@
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 '../../../core/widgets/app_scaffold.dart';
class SettingsHomeScreen extends StatelessWidget {
class SettingsHomeScreen extends ConsumerWidget {
const SettingsHomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Settings'),
Widget build(BuildContext context, WidgetRef ref) {
return AppScaffold(
body: ListView(
children: [
_buildSection(
context,
title: 'Account',
children: [
_SettingsTile(
icon: Icons.person,
title: 'Edit Profile',
subtitle: 'Update your avatar, username, or bio',
onTap: () => context.push('/profile/edit'),
),
_SettingsTile(
icon: Icons.email,
title: 'Email',
subtitle: supabase.Supabase.instance.client.auth.currentUser?.email ?? '',
onTap: () => context.push('/settings/account'),
),
_SettingsTile(
icon: Icons.lock,
title: 'Change Password',
subtitle: 'Update your password',
onTap: () => context.push('/settings/account/password'),
),
],
),
_buildSection(
context,
title: 'Preferences',
children: [
_SettingsTile(
icon: Icons.palette,
title: 'Appearance',
subtitle: 'Theme, time format',
onTap: () => context.push('/settings/appearance'),
),
_SettingsTile(
icon: Icons.notifications,
title: 'Notifications',
subtitle: 'Reminders and alerts',
onTap: () => context.push('/settings/notifications'),
),
],
),
_buildSection(
context,
title: 'Privacy',
children: [
_SettingsTile(
icon: Icons.visibility,
title: 'Profile Visibility',
subtitle: 'Public or Private profile',
onTap: () => context.push('/settings/privacy'),
),
_SettingsTile(
icon: Icons.block,
title: 'Blocked Users',
subtitle: 'Manage blocked accounts',
onTap: () => context.push('/settings/privacy/blocked'),
),
],
),
_buildSection(
context,
title: 'About',
children: [
_SettingsTile(
icon: Icons.info_outline,
title: 'About the Challenge',
subtitle: 'Learn about the 1356-day challenge',
onTap: () => context.push('/settings/about'),
),
_SettingsTile(
icon: Icons.description,
title: 'Terms of Service',
subtitle: 'Legal terms and conditions',
onTap: () => _showTermsOfService(context),
),
_SettingsTile(
icon: Icons.privacy_tip,
title: 'Privacy Policy',
subtitle: 'How we handle your data',
onTap: () => _showPrivacyPolicy(context),
),
],
),
_buildSection(
context,
title: 'Danger Zone',
children: [
_SettingsTile(
icon: Icons.delete_forever,
title: 'Delete Account',
subtitle: 'Permanently delete your account and data',
onTap: () => _showDeleteAccountDialog(context),
isDestructive: true,
),
],
),
const SizedBox(height: 32),
Center(
child: Text(
'LifeTimer v1.0.0',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(height: 16),
],
),
body: const Center(
child: Text('Settings - Coming Soon'),
);
}
Widget _buildSection(BuildContext context, {
required String title,
required List<Widget> children,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
...children,
],
);
}
void _showTermsOfService(BuildContext context) {
showDialog(
context: context,
builder: (context) => Semantics(
label: 'Terms of Service dialog',
child: AlertDialog(
title: const Text('Terms of Service'),
content: const SingleChildScrollView(
child: Text(
'LifeTimer Terms of Service\n\n'
'1. Acceptance of Terms\n'
'By using LifeTimer, you agree to these terms.\n\n'
'2. User Responsibilities\n'
'Users are responsible for maintaining the security of their account.\n\n'
'3. Content\n'
'Users own their goals and progress data.\n\n'
'4. Service Availability\n'
'We strive to keep the service available but cannot guarantee 100% uptime.\n\n'
'5. Changes to Terms\n'
'We may update these terms from time to time.',
),
),
actions: [
Semantics(
button: true,
label: 'Close terms of service',
child: TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
),
],
),
),
);
}
void _showPrivacyPolicy(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Privacy Policy'),
content: const SingleChildScrollView(
child: Text(
'LifeTimer Privacy Policy\n\n'
'1. Data Collection\n'
'We collect only the data necessary to provide the service.\n\n'
'2. Data Usage\n'
'Your data is used to track your goals and countdown progress.\n\n'
'3. Data Security\n'
'We use industry-standard security measures to protect your data.\n\n'
'4. Public Profiles\n'
'You can choose to make your profile public or private.\n\n'
'5. Data Deletion\n'
'You can request deletion of your account and associated data.',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
);
}
void _showDeleteAccountDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => Semantics(
label: 'Delete account confirmation dialog',
child: AlertDialog(
title: const Text('Delete Account'),
content: const Text(
'Are you sure you want to delete your account? This action cannot be undone and all your data will be permanently lost.',
),
actions: [
Semantics(
button: true,
label: 'Cancel account deletion',
child: TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
),
Semantics(
button: true,
label: 'Confirm account deletion',
hint: 'This action cannot be undone',
child: TextButton(
onPressed: () {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Account deletion requires confirmation via email')),
);
},
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
child: const Text('Delete'),
),
),
],
),
),
);
}
}
class _SettingsTile extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
final bool isDestructive;
const _SettingsTile({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
this.isDestructive = false,
});
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: title,
hint: subtitle,
child: ListTile(
leading: Icon(
icon,
color: isDestructive
? Theme.of(context).colorScheme.error
: Theme.of(context).colorScheme.primary,
),
title: Text(
title,
style: TextStyle(
color: isDestructive
? Theme.of(context).colorScheme.error
: null,
),
),
subtitle: Text(
subtitle,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
trailing: const Icon(Icons.chevron_right),
onTap: onTap,
),
);
}
@@ -0,0 +1,161 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../data/models/user_model.dart' as app;
import '../../../data/models/activity_model.dart';
import '../../../data/repositories/social_repository.dart';
import '../../../bootstrap/supabase_client.dart';
import '../../auth/application/auth_controller.dart';
class SocialController extends StateNotifier<SocialState> {
final SocialRepository _repository;
final String _currentUserId;
SocialController(this._repository, this._currentUserId)
: super(const SocialState.initial());
Future<void> loadFollowers(String userId) async {
state = const SocialState.loading();
try {
final followers = await _repository.getFollowers(userId);
state = SocialState.followersLoaded(followers);
} catch (e) {
state = SocialState.error(e.toString());
}
}
Future<void> loadFollowing(String userId) async {
state = const SocialState.loading();
try {
final following = await _repository.getFollowing(userId);
state = SocialState.followingLoaded(following);
} catch (e) {
state = SocialState.error(e.toString());
}
}
Future<void> loadActivityFeed(String userId) async {
state = const SocialState.loading();
try {
final activities = await _repository.getActivityFeed(userId);
state = SocialState.feedLoaded(activities);
} catch (e) {
state = SocialState.error(e.toString());
}
}
Future<void> followUser(String targetUserId) async {
try {
await _repository.followUser(_currentUserId, targetUserId);
await _repository.logActivity(
userId: _currentUserId,
type: 'followed_user',
payload: {'target_user_id': targetUserId},
);
} catch (e) {
state = SocialState.error(e.toString());
}
}
Future<void> unfollowUser(String targetUserId) async {
try {
await _repository.unfollowUser(_currentUserId, targetUserId);
} catch (e) {
state = SocialState.error(e.toString());
}
}
Future<bool> isFollowing(String targetUserId) async {
try {
return await _repository.isFollowing(_currentUserId, targetUserId);
} catch (e) {
return false;
}
}
Future<void> loadLeaderboard({required String sortBy, int limit = 50}) async {
state = const SocialState.loading();
try {
final leaderboard = await _repository.getLeaderboard(
sortBy: sortBy,
limit: limit,
);
state = SocialState.leaderboardLoaded(leaderboard);
} catch (e) {
state = SocialState.error(e.toString());
}
}
Future<void> logGoalCompletion(String goalId) async {
try {
await _repository.logActivity(
userId: _currentUserId,
type: 'goal_completed',
payload: {'goal_id': goalId},
);
} catch (e) {
state = SocialState.error(e.toString());
}
}
Future<void> logMilestoneCompletion(String goalId, String milestone) async {
try {
await _repository.logActivity(
userId: _currentUserId,
type: 'milestone_completed',
payload: {
'goal_id': goalId,
'milestone': milestone,
},
);
} catch (e) {
state = SocialState.error(e.toString());
}
}
}
class SocialState {
final bool isLoading;
final List<app.User>? followers;
final List<app.User>? following;
final List<Activity>? feed;
final List<app.User>? leaderboard;
final String? error;
const SocialState({
this.isLoading = false,
this.followers,
this.following,
this.feed,
this.leaderboard,
this.error,
});
const SocialState.initial() : isLoading = false, followers = null, following = null, feed = null, leaderboard = null, error = null;
const SocialState.loading() : isLoading = true, followers = null, following = null, feed = null, leaderboard = null, error = null;
const SocialState.followersLoaded(this.followers) : isLoading = false, following = null, feed = null, leaderboard = null, error = null;
const SocialState.followingLoaded(this.following) : isLoading = false, followers = null, feed = null, leaderboard = null, error = null;
const SocialState.feedLoaded(this.feed) : isLoading = false, followers = null, following = null, leaderboard = null, error = null;
const SocialState.leaderboardLoaded(this.leaderboard) : isLoading = false, followers = null, following = null, feed = null, error = null;
const SocialState.error(this.error) : isLoading = false, followers = null, following = null, feed = null, leaderboard = null;
}
final socialRepositoryProvider = Provider<SocialRepository>((ref) {
return SocialRepository(supabaseClient);
});
final socialControllerProvider = StateNotifierProvider<SocialController, SocialState>((ref) {
final repository = ref.watch(socialRepositoryProvider);
final authController = ref.read(authControllerProvider.notifier);
final currentUserId = authController.currentUserId ?? '';
if (currentUserId.isEmpty) {
return SocialController(repository, 'placeholder_user_id');
}
return SocialController(repository, currentUserId);
});
@@ -0,0 +1,170 @@
// ignore_for_file: unused_field
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../data/repositories/notifications_repository.dart';
import '../../../data/repositories/social_repository.dart';
import '../../../bootstrap/supabase_client.dart';
import '../../auth/application/auth_controller.dart';
class SocialNotificationsState {
final bool isLoading;
final String? error;
final bool followNotificationsEnabled;
final bool milestoneNotificationsEnabled;
final bool leaderboardNotificationsEnabled;
const SocialNotificationsState({
this.isLoading = false,
this.error,
this.followNotificationsEnabled = true,
this.milestoneNotificationsEnabled = true,
this.leaderboardNotificationsEnabled = true,
});
SocialNotificationsState copyWith({
bool? isLoading,
String? error,
bool? followNotificationsEnabled,
bool? milestoneNotificationsEnabled,
bool? leaderboardNotificationsEnabled,
}) {
return SocialNotificationsState(
isLoading: isLoading ?? this.isLoading,
error: error,
followNotificationsEnabled: followNotificationsEnabled ?? this.followNotificationsEnabled,
milestoneNotificationsEnabled: milestoneNotificationsEnabled ?? this.milestoneNotificationsEnabled,
leaderboardNotificationsEnabled: leaderboardNotificationsEnabled ?? this.leaderboardNotificationsEnabled,
);
}
}
class SocialNotificationsController extends StateNotifier<SocialNotificationsState> {
final NotificationsRepository _notificationsRepository;
final SocialRepository _socialRepository;
final AuthController _authController;
SocialNotificationsController(
this._notificationsRepository,
this._socialRepository,
this._authController,
) : super(const SocialNotificationsState());
Future<void> toggleFollowNotifications(bool enabled) async {
state = state.copyWith(isLoading: true);
try {
state = state.copyWith(
isLoading: false,
followNotificationsEnabled: enabled,
);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: e.toString(),
);
}
}
Future<void> toggleMilestoneNotifications(bool enabled) async {
state = state.copyWith(isLoading: true);
try {
state = state.copyWith(
isLoading: false,
milestoneNotificationsEnabled: enabled,
);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: e.toString(),
);
}
}
Future<void> toggleLeaderboardNotifications(bool enabled) async {
state = state.copyWith(isLoading: true);
try {
state = state.copyWith(
isLoading: false,
leaderboardNotificationsEnabled: enabled,
);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: e.toString(),
);
}
}
Future<void> sendFollowNotification(String followerUserId, String followerUsername) async {
if (!state.followNotificationsEnabled) return;
try {
await _notificationsRepository.showNotification(
id: DateTime.now().millisecondsSinceEpoch,
title: 'New Follower!',
body: '$followerUsername started following you',
payload: 'follow_notification',
);
} catch (e) {
state = state.copyWith(error: e.toString());
}
}
Future<void> sendMilestoneNotification(
String userId,
String username,
String goalTitle,
) async {
if (!state.milestoneNotificationsEnabled) return;
try {
await _notificationsRepository.showNotification(
id: DateTime.now().millisecondsSinceEpoch,
title: 'Milestone Completed!',
body: '$username completed: $goalTitle',
payload: 'milestone_notification',
);
} catch (e) {
state = state.copyWith(error: e.toString());
}
}
Future<void> sendLeaderboardNotification(String message) async {
if (!state.leaderboardNotificationsEnabled) return;
try {
await _notificationsRepository.showNotification(
id: DateTime.now().millisecondsSinceEpoch,
title: 'Leaderboard Update',
body: message,
payload: 'leaderboard_notification',
);
} catch (e) {
state = state.copyWith(error: e.toString());
}
}
void clearError() {
state = state.copyWith(error: null);
}
}
final socialNotificationsControllerProvider =
StateNotifierProvider<SocialNotificationsController, SocialNotificationsState>((ref) {
final notificationsRepository = ref.watch(notificationsRepositoryProvider);
final socialRepository = ref.watch(socialRepositoryProvider);
final authController = ref.watch(authControllerProvider.notifier);
return SocialNotificationsController(
notificationsRepository,
socialRepository,
authController,
);
});
final socialRepositoryProvider = Provider<SocialRepository>((ref) {
return SocialRepository(supabaseClient);
});
final notificationsRepositoryProvider = Provider<NotificationsRepository>((ref) {
return NotificationsRepository();
});
@@ -0,0 +1,218 @@
// ignore_for_file: deprecated_member_use
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/widgets/app_scaffold.dart';
import '../../../core/widgets/bottom_nav_scaffold.dart';
import '../../../core/widgets/loading_indicator.dart';
import '../../../core/widgets/empty_state.dart';
import '../../../data/models/user_model.dart' as app;
import '../application/social_controller.dart';
class LeaderboardsScreen extends ConsumerStatefulWidget {
const LeaderboardsScreen({super.key});
@override
ConsumerState<LeaderboardsScreen> createState() => _LeaderboardsScreenState();
}
class _LeaderboardsScreenState extends ConsumerState<LeaderboardsScreen> {
String _selectedSort = 'goals_completed';
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadLeaderboard();
});
}
Future<void> _loadLeaderboard() async {
await ref.read(socialControllerProvider.notifier).loadLeaderboard(
sortBy: _selectedSort,
limit: 50,
);
}
void _onSortChanged(String sortBy) {
setState(() {
_selectedSort = sortBy;
});
_loadLeaderboard();
}
@override
Widget build(BuildContext context) {
final socialState = ref.watch(socialControllerProvider);
return BottomNavScaffold(
child: AppScaffold(
title: 'Leaderboards',
body: Column(
children: [
_SortTabs(
selectedSort: _selectedSort,
onSortChanged: _onSortChanged,
),
Expanded(child: _buildBody(socialState)),
],
),
),
);
}
Widget _buildBody(SocialState state) {
if (state.isLoading) {
return const Center(child: LoadingIndicator());
}
if (state.error != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 16),
Text('Error: ${state.error}'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadLeaderboard,
child: const Text('Retry'),
),
],
),
);
}
if (state.leaderboard == null || state.leaderboard!.isEmpty) {
return const EmptyState(
icon: Icons.emoji_events_outlined,
title: 'No Leaderboard Yet',
subtitle: 'Be the first to complete goals and appear on the leaderboard!',
);
}
return RefreshIndicator(
onRefresh: _loadLeaderboard,
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: state.leaderboard!.length,
itemBuilder: (context, index) {
final user = state.leaderboard![index];
return _LeaderboardEntry(
user: user,
rank: index + 1,
);
},
),
);
}
}
class _SortTabs extends StatelessWidget {
final String selectedSort;
final Function(String) onSortChanged;
const _SortTabs({
required this.selectedSort,
required this.onSortChanged,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: SegmentedButton<String>(
segments: const [
ButtonSegment(
value: 'goals_completed',
label: Text('Goals'),
icon: Icon(Icons.flag),
),
ButtonSegment(
value: 'streak',
label: Text('Streak'),
icon: Icon(Icons.local_fire_department),
),
],
selected: {selectedSort},
onSelectionChanged: (Set<String> selected) {
onSortChanged(selected.first);
},
),
);
}
}
class _LeaderboardEntry extends StatelessWidget {
final app.User user;
final int rank;
const _LeaderboardEntry({
required this.user,
required this.rank,
});
@override
Widget build(BuildContext context) {
Color rankColor;
IconData rankIcon;
if (rank == 1) {
rankColor = Colors.amber;
rankIcon = Icons.emoji_events;
} else if (rank == 2) {
rankColor = Colors.grey;
rankIcon = Icons.military_tech;
} else if (rank == 3) {
rankColor = Colors.brown;
rankIcon = Icons.workspace_premium;
} else {
rankColor = Theme.of(context).colorScheme.primary;
rankIcon = Icons.numbers;
}
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: rankColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(24),
),
child: Center(
child: Icon(
rankIcon,
color: rankColor,
size: 28,
),
),
),
title: Text(
user.username,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
'Member since ${user.createdAt.year}',
style: Theme.of(context).textTheme.bodySmall,
),
trailing: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'#$rank',
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryContainer,
fontWeight: FontWeight.bold,
),
),
),
),
);
}
}
@@ -0,0 +1,318 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../../../core/widgets/app_scaffold.dart';
import '../../../core/widgets/loading_indicator.dart';
import '../../../core/utils/date_time_utils.dart';
import '../../../data/models/user_model.dart' as app;
import '../../auth/application/auth_controller.dart';
import '../application/social_controller.dart';
import '../../profile/application/profile_controller.dart';
class PublicProfileScreen extends ConsumerStatefulWidget {
final String userId;
const PublicProfileScreen({
super.key,
required this.userId,
});
@override
ConsumerState<PublicProfileScreen> createState() => _PublicProfileScreenState();
}
class _PublicProfileScreenState extends ConsumerState<PublicProfileScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadProfile();
_checkFollowingStatus();
});
}
Future<void> _loadProfile() async {
await ref.read(profileControllerProvider.notifier).loadProfile(widget.userId);
}
Future<void> _checkFollowingStatus() async {
await ref.read(socialControllerProvider.notifier).isFollowing(widget.userId);
}
Future<void> _toggleFollow() async {
final controller = ref.read(socialControllerProvider.notifier);
final isFollowing = await controller.isFollowing(widget.userId);
if (isFollowing) {
await controller.unfollowUser(widget.userId);
} else {
await controller.followUser(widget.userId);
}
setState(() {});
}
@override
Widget build(BuildContext context) {
final profileState = ref.watch(profileControllerProvider);
final authController = ref.watch(authControllerProvider);
final isOwnProfile = authController?.id == widget.userId;
return AppScaffold(
title: 'Profile',
body: _buildBody(profileState, isOwnProfile),
);
}
Widget _buildBody(ProfileState state, bool isOwnProfile) {
if (state.isLoading) {
return const Center(child: LoadingIndicator());
}
if (state.errorMessage != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 16),
Text('Error: ${state.errorMessage}'),
],
),
);
}
final user = state.user;
if (user == null) {
return const Center(child: Text('User not found'));
}
return RefreshIndicator(
onRefresh: () async {
await _loadProfile();
await _checkFollowingStatus();
},
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: _ProfileHeader(
user: user,
isOwnProfile: isOwnProfile,
onToggleFollow: isOwnProfile ? () {} : _toggleFollow,
),
),
SliverToBoxAdapter(
child: _StatsSection(user: user),
),
],
),
);
}
}
class _ProfileHeader extends ConsumerWidget {
final app.User user;
final bool isOwnProfile;
final VoidCallback onToggleFollow;
const _ProfileHeader({
required this.user,
required this.isOwnProfile,
required this.onToggleFollow,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Container(
padding: const EdgeInsets.all(24),
child: Column(
children: [
CircleAvatar(
radius: 50,
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
backgroundImage: user.avatarUrl != null
? CachedNetworkImageProvider(user.avatarUrl!)
: null,
child: user.avatarUrl == null
? Text(
user.username.substring(0, 2).toUpperCase(),
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
)
: null,
),
const SizedBox(height: 16),
Text(
user.username,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
if (user.bio != null && user.bio!.isNotEmpty)
Text(
user.bio!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
if (user.isPublicProfile &&
((user.twitterHandle != null && user.twitterHandle!.isNotEmpty) ||
(user.instagramHandle != null && user.instagramHandle!.isNotEmpty) ||
(user.tiktokHandle != null && user.tiktokHandle!.isNotEmpty) ||
(user.websiteUrl != null && user.websiteUrl!.isNotEmpty)))
Wrap(
spacing: 8,
runSpacing: 4,
alignment: WrapAlignment.center,
children: [
if (user.twitterHandle != null && user.twitterHandle!.isNotEmpty)
Chip(
avatar: const Icon(Icons.alternate_email, size: 16),
label: Text(user.twitterHandle!),
),
if (user.instagramHandle != null && user.instagramHandle!.isNotEmpty)
Chip(
avatar: const Icon(Icons.camera_alt_outlined, size: 16),
label: Text(user.instagramHandle!),
),
if (user.tiktokHandle != null && user.tiktokHandle!.isNotEmpty)
Chip(
avatar: const Icon(Icons.music_note_outlined, size: 16),
label: Text(user.tiktokHandle!),
),
if (user.websiteUrl != null && user.websiteUrl!.isNotEmpty)
Chip(
avatar: const Icon(Icons.link, size: 16),
label: Text(user.websiteUrl!),
),
],
),
const SizedBox(height: 16),
if (!isOwnProfile)
_FollowButton(onToggleFollow: onToggleFollow),
],
),
);
}
}
class _FollowButton extends StatelessWidget {
final VoidCallback onToggleFollow;
const _FollowButton({required this.onToggleFollow});
@override
Widget build(BuildContext context) {
return ElevatedButton.icon(
onPressed: onToggleFollow,
icon: const Icon(Icons.person_add),
label: const Text('Follow'),
style: ElevatedButton.styleFrom(
minimumSize: const Size(120, 40),
),
);
}
}
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;
final String value;
const _StatCard({
required this.icon,
required this.title,
required this.value,
});
@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: Row(
children: [
Icon(
icon,
color: Theme.of(context).colorScheme.primary,
size: 32,
),
const SizedBox(width: 16),
Expanded(
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),
Text(
value,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
);
}
}
@@ -1,16 +1,160 @@
import 'package:flutter/material.dart';
// ignore_for_file: deprecated_member_use
class SocialFeedScreen extends StatelessWidget {
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/widgets/app_scaffold.dart';
import '../../../core/widgets/bottom_nav_scaffold.dart';
import '../../../core/widgets/loading_indicator.dart';
import '../../../core/widgets/empty_state.dart';
import '../../../core/utils/date_time_utils.dart';
import '../../../data/models/activity_model.dart';
import '../../auth/application/auth_controller.dart';
import '../application/social_controller.dart';
class SocialFeedScreen extends ConsumerStatefulWidget {
const SocialFeedScreen({super.key});
@override
ConsumerState<SocialFeedScreen> createState() => _SocialFeedScreenState();
}
class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadFeed();
});
}
Future<void> _loadFeed() async {
final authController = ref.read(authControllerProvider.notifier);
final userId = authController.currentUserId;
if (userId != null) {
await ref.read(socialControllerProvider.notifier).loadActivityFeed(userId);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Social'),
final socialState = ref.watch(socialControllerProvider);
return BottomNavScaffold(
child: AppScaffold(
title: 'Social Feed',
body: _buildBody(socialState),
),
body: const Center(
child: Text('Social Feed - Coming Soon'),
);
}
Widget _buildBody(SocialState state) {
if (state.isLoading) {
return const Center(child: LoadingIndicator());
}
if (state.error != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 16),
Text('Error: ${state.error}'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadFeed,
child: const Text('Retry'),
),
],
),
);
}
if (state.feed == null || state.feed!.isEmpty) {
return const EmptyState(
icon: Icons.feed_outlined,
title: 'No Activity Yet',
subtitle: 'Follow users to see their progress and milestones here.',
);
}
return RefreshIndicator(
onRefresh: _loadFeed,
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: state.feed!.length,
itemBuilder: (context, index) {
final activity = state.feed![index];
return _ActivityCard(activity: activity);
},
),
);
}
}
class _ActivityCard extends StatelessWidget {
final Activity activity;
const _ActivityCard({required this.activity});
@override
Widget build(BuildContext context) {
IconData icon;
Color iconColor;
String title;
String? subtitle;
switch (activity.type) {
case 'goal_completed':
icon = Icons.celebration;
iconColor = Colors.green;
title = 'Completed a goal!';
subtitle = activity.payload?['goal_title'] as String?;
break;
case 'milestone_completed':
icon = Icons.flag;
iconColor = Colors.blue;
title = 'Reached a milestone';
subtitle = activity.payload?['milestone'] as String?;
break;
case 'followed_user':
icon = Icons.person_add;
iconColor = Colors.purple;
title = 'Started following someone';
break;
case 'countdown_started':
icon = Icons.timer;
iconColor = Colors.orange;
title = 'Started their 1356-day journey!';
break;
default:
icon = Icons.info_outline;
iconColor = Colors.grey;
title = 'Activity';
}
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
leading: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(24),
),
child: Icon(icon, color: iconColor, size: 28),
),
title: Text(title),
subtitle: subtitle != null
? Text(subtitle, maxLines: 2, overflow: TextOverflow.ellipsis)
: null,
trailing: Text(
DateTimeUtils.formatRelativeTime(activity.createdAt),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
);
}
@@ -0,0 +1,183 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../bootstrap/env.dart';
import '../../../data/services/mistral_ai_service.dart';
import '../../../data/services/voice_recording_service.dart';
final voiceRecordingControllerProvider =
StateNotifierProvider<VoiceRecordingController, VoiceRecordingState>((ref) {
final mistralService = MistralAIService(apiKey: Env.mistralApiKey);
final voiceService = VoiceRecordingService(mistralService: mistralService);
return VoiceRecordingController(voiceService, mistralService);
});
class VoiceRecordingState {
final bool isRecording;
final bool isProcessing;
final Duration elapsed;
final String? transcript;
final String? error;
final List<double> levels;
const VoiceRecordingState({
this.isRecording = false,
this.isProcessing = false,
this.elapsed = Duration.zero,
this.transcript,
this.error,
this.levels = const [],
});
VoiceRecordingState copyWith({
bool? isRecording,
bool? isProcessing,
Duration? elapsed,
String? transcript,
String? error,
List<double>? levels,
}) {
return VoiceRecordingState(
isRecording: isRecording ?? this.isRecording,
isProcessing: isProcessing ?? this.isProcessing,
elapsed: elapsed ?? this.elapsed,
transcript: transcript ?? this.transcript,
error: error ?? this.error,
levels: levels ?? this.levels,
);
}
}
class VoiceRecordingController extends StateNotifier<VoiceRecordingState> {
final VoiceRecordingService _voiceService;
final MistralAIService _mistralService;
final Random _random = Random();
Timer? _ticker;
VoiceRecordingController(this._voiceService, this._mistralService)
: super(const VoiceRecordingState());
Future<void> startRecording() async {
if (state.isRecording || state.isProcessing) {
return;
}
try {
await _voiceService.startRecording();
_startTicker();
state = state.copyWith(
isRecording: true,
isProcessing: false,
elapsed: Duration.zero,
error: null,
transcript: null,
levels: _generateWaveform(),
);
} catch (e) {
state = state.copyWith(error: e.toString());
}
}
Future<void> stopRecording() async {
if (!state.isRecording) {
return;
}
_stopTicker();
state = state.copyWith(
isRecording: false,
isProcessing: true,
error: null,
);
try {
final audioPath = await _voiceService.stopRecording();
if (audioPath.isEmpty) {
state = state.copyWith(
isProcessing: false,
error: 'Failed to save recording',
);
return;
}
final transcription = await _voiceService.transcribeRecording(
audioFilePath: audioPath,
);
state = state.copyWith(
isProcessing: false,
transcript: transcription.isNotEmpty
? transcription
: 'No speech detected. Please try again.',
);
} catch (e) {
state = state.copyWith(
isProcessing: false,
error: e.toString(),
);
}
}
Future<void> cancelRecording() async {
if (!state.isRecording) {
return;
}
try {
await _voiceService.cancelRecording();
} catch (_) {}
_stopTicker();
state = state.copyWith(
isRecording: false,
isProcessing: false,
elapsed: Duration.zero,
levels: const [],
);
}
void reset() {
state = const VoiceRecordingState();
}
void clearError() {
state = state.copyWith(error: null);
}
List<double> _generateWaveform() {
return List<double>.generate(40, (index) {
final base = 0.2 + _random.nextDouble() * 0.6;
final wave = sin(index / 2).abs();
return (base + wave) / 2;
});
}
void _startTicker() {
_ticker?.cancel();
_ticker = Timer.periodic(const Duration(milliseconds: 120), (_) {
final newElapsed = state.elapsed + const Duration(milliseconds: 120);
state = state.copyWith(
elapsed: newElapsed,
levels: _generateWaveform(),
);
});
}
void _stopTicker() {
_ticker?.cancel();
_ticker = null;
}
@override
void dispose() {
_stopTicker();
_voiceService.dispose();
_mistralService.dispose();
super.dispose();
}
}
@@ -0,0 +1,316 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/widgets/app_scaffold.dart';
import '../application/voice_recording_controller.dart';
class VoiceRecordingScreen extends ConsumerWidget {
const VoiceRecordingScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(voiceRecordingControllerProvider);
final controller = ref.read(voiceRecordingControllerProvider.notifier);
final theme = Theme.of(context);
final textTheme = theme.textTheme;
final colorScheme = theme.colorScheme;
final elapsedText = _formatDuration(state.elapsed);
return AppScaffold(
title: 'Recording',
body: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 8),
Text(
state.isRecording ? 'Recording in progress' : 'Voice notes',
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface.withValues(alpha:0.7),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
Center(
child: Text(
elapsedText,
style: textTheme.displaySmall?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: 2,
),
),
),
const SizedBox(height: 24),
_Waveform(state: state),
const SizedBox(height: 24),
Expanded(
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: Colors.black.withValues(alpha:0.04),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha:0.04),
blurRadius: 20,
offset: const Offset(0, 12),
),
],
),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: state.isRecording
? colorScheme.error
: colorScheme.primary,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text(
state.isRecording
? 'Listening...'
: state.isProcessing
? 'Transcribing your note'
: 'Transcript',
style: textTheme.labelLarge?.copyWith(
color: colorScheme.onSurface.withValues(alpha:0.7),
),
),
],
),
const SizedBox(height: 12),
Text(
state.transcript ??
(state.isRecording
? 'Start speaking to capture your thoughts.'
: 'When you finish recording, your words will appear here as clean text.'),
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withValues(alpha:0.9),
),
),
if (state.error != null) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
Icons.error_outline,
color: colorScheme.onErrorContainer,
),
const SizedBox(width: 8),
Expanded(
child: Text(
state.error!,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onErrorContainer,
),
),
),
IconButton(
onPressed: controller.clearError,
icon: const Icon(Icons.close, size: 16),
color: colorScheme.onErrorContainer,
),
],
),
),
],
],
),
),
),
),
],
),
),
bottomNavigationBar: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
child: Row(
children: [
_CircleIconButton(
icon: Icons.delete_outline,
onPressed: state.isRecording || state.isProcessing
? null
: () => controller.reset(),
),
const SizedBox(width: 16),
Expanded(
child: SizedBox(
height: 56,
child: ElevatedButton(
onPressed: state.isProcessing
? null
: () {
if (state.isRecording) {
controller.stopRecording();
} else {
controller.startRecording();
}
},
style: ElevatedButton.styleFrom(
backgroundColor: state.isRecording
? colorScheme.error
: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(999),
),
),
child: state.isProcessing
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
colorScheme.onPrimary,
),
),
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
state.isRecording ? Icons.stop : Icons.mic,
),
const SizedBox(width: 8),
Text(
state.isRecording ? 'Stop' : 'Start',
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onPrimary,
),
),
],
),
),
),
),
const SizedBox(width: 16),
_CircleIconButton(
icon: Icons.check,
onPressed: state.transcript != null &&
!state.isRecording &&
!state.isProcessing
? () => _copyTranscript(context, state.transcript!)
: null,
),
],
),
),
),
);
}
}
class _Waveform extends StatelessWidget {
final VoiceRecordingState state;
const _Waveform({required this.state});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final levels = state.levels.isNotEmpty
? state.levels
: List<double>.filled(40, 0.2);
return SizedBox(
height: 96,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (final level in levels)
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 1),
child: Container(
height: 24 + level * 60,
decoration: BoxDecoration(
color: colorScheme.onSurface.withValues(alpha:0.08),
borderRadius: BorderRadius.circular(4),
),
),
),
),
],
),
);
}
}
class _CircleIconButton extends StatelessWidget {
final IconData icon;
final VoidCallback? onPressed;
const _CircleIconButton({
required this.icon,
this.onPressed,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return SizedBox(
width: 48,
height: 48,
child: Material(
color: onPressed == null
? colorScheme.surfaceContainerHighest.withValues(alpha:0.4)
: colorScheme.surface,
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: onPressed,
child: Icon(
icon,
size: 22,
color: onPressed == null
? colorScheme.onSurface.withValues(alpha:0.3)
: colorScheme.onSurface,
),
),
),
);
}
}
String _formatDuration(Duration duration) {
final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0');
final centiseconds =
(duration.inMilliseconds.remainder(1000) ~/ 10).toString().padLeft(2, '0');
return '$minutes:$seconds:$centiseconds';
}
Future<void> _copyTranscript(BuildContext context, String transcript) async {
await Clipboard.setData(ClipboardData(text: transcript));
if (!context.mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Transcription copied to clipboard')),
);
}