mirror of
https://github.com/Dvorinka/1356.git
synced 2026-06-05 04:22:55 +00:00
feat: Complete Phase 1 - Full Flutter app implementation with comprehensive features
Version: 1.1.0 Major changes: - Implemented complete Flutter app structure with all core features - Added comprehensive UI screens for auth, countdown, goals, profile, settings, and social features - Integrated Supabase backend with authentication and data repositories - Added offline support with Hive caching and local storage - Implemented comprehensive routing with go_router - Added location services with Google Maps integration - Implemented notifications and home widget support - Added voice recording capabilities and AI chat features - Created comprehensive test suite and documentation - Added Android and iOS platform configurations - Implemented achievements system and social features - Added calendar integration and bucket list functionality This represents a complete Phase 1 milestone with 3,775 additions across 31 files.
This commit is contained in:
@@ -0,0 +1,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',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+135
@@ -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')),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user