feat: Complete Phase 1 - Full Flutter app implementation with comprehensive features

Version: 1.1.0

Major changes:
- Implemented complete Flutter app structure with all core features
- Added comprehensive UI screens for auth, countdown, goals, profile, settings, and social features
- Integrated Supabase backend with authentication and data repositories
- Added offline support with Hive caching and local storage
- Implemented comprehensive routing with go_router
- Added location services with Google Maps integration
- Implemented notifications and home widget support
- Added voice recording capabilities and AI chat features
- Created comprehensive test suite and documentation
- Added Android and iOS platform configurations
- Implemented achievements system and social features
- Added calendar integration and bucket list functionality

This represents a complete Phase 1 milestone with 3,775 additions across 31 files.
This commit is contained in:
Tomas Dvorak
2026-01-04 14:33:54 +01:00
parent 1a29315672
commit 37ffb93923
210 changed files with 29417 additions and 477 deletions
@@ -0,0 +1,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,
),
),
],
),
),
],
),
),
);
}
}