Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions lib/core/router/app_router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import '../../features/notes/views/notes_list_page.dart';
import '../../features/notes/views/note_editor_page.dart';
import '../../features/profile/views/app_customization_page.dart';
import '../../features/profile/views/profile_page.dart';
import '../../features/wrapped/views/wrapped_page.dart';
import '../../l10n/app_localizations.dart';
import '../models/server_config.dart';

Expand Down Expand Up @@ -299,6 +300,11 @@ final goRouterProvider = Provider<GoRouter>((ref) {
return NoteEditorPage(noteId: noteId);
},
),
GoRoute(
path: Routes.wrapped,
name: RouteNames.wrapped,
builder: (context, state) => const WrappedPage(),
),
];

final router = GoRouter(
Expand Down
2 changes: 2 additions & 0 deletions lib/core/services/navigation_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ class Routes {
static const String appCustomization = '/profile/customization';
static const String notes = '/notes';
static const String noteEditor = '/notes/:id';
static const String wrapped = '/wrapped';
}

/// Friendly names for GoRouter routes to support context.pushNamed.
Expand All @@ -123,4 +124,5 @@ class RouteNames {
static const String appCustomization = 'app-customization';
static const String notes = 'notes';
static const String noteEditor = 'note-editor';
static const String wrapped = 'wrapped';
}
58 changes: 58 additions & 0 deletions lib/features/profile/views/profile_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@ class ProfilePage extends ConsumerWidget {

Widget _buildAccountSection(BuildContext context, WidgetRef ref) {
final items = [
_build2025WrappedTile(context),
_buildDefaultModelTile(context, ref),
_buildAccountOption(
context,
Expand Down Expand Up @@ -534,6 +535,63 @@ class ProfilePage extends ConsumerWidget {

// Theme and language controls moved to AppCustomizationPage.

Widget _build2025WrappedTile(BuildContext context) {
final theme = context.conduitTheme;
return _ProfileSettingTile(
onTap: () {
context.pushNamed(RouteNames.wrapped);
},
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
const Color(0xFF667eea),
const Color(0xFF764ba2),
],
),
borderRadius: BorderRadius.circular(AppBorderRadius.small),
border: Border.all(
color: Colors.white.withValues(alpha: 0.2),
width: BorderWidth.thin,
),
),
alignment: Alignment.center,
child: const Text('✨', style: TextStyle(fontSize: 20)),
),
title: '2025 Wrapped',
subtitle: 'Your year in AI conversations',
trailing: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
theme.buttonPrimary.withValues(alpha: 0.2),
theme.buttonPrimary.withValues(alpha: 0.1),
],
),
borderRadius: BorderRadius.circular(AppBorderRadius.chip),
border: Border.all(
color: theme.buttonPrimary.withValues(alpha: 0.3),
width: BorderWidth.thin,
),
),
child: Text(
'NEW',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: theme.buttonPrimary,
letterSpacing: 0.5,
),
),
),
);
}

Widget _buildAboutTile(BuildContext context) {
return _buildAccountOption(
context,
Expand Down
194 changes: 194 additions & 0 deletions lib/features/wrapped/models/wrapped_stats.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import 'package:flutter/foundation.dart';

/// Statistics for a user's 2025 Conduit Wrapped experience.
///
/// Computes various fun metrics from chat history to create
/// a personalized year-in-review summary.
@immutable
class WrappedStats {
const WrappedStats({
required this.totalConversations,
required this.totalMessages,
required this.totalUserMessages,
required this.totalAssistantMessages,
required this.favoriteModel,
required this.favoriteModelMessageCount,
required this.modelUsageCounts,
required this.busiestMonth,
required this.busiestMonthMessageCount,
required this.monthlyMessageCounts,
required this.longestConversationTitle,
required this.longestConversationMessageCount,
required this.averageMessagesPerConversation,
required this.totalCharactersTyped,
required this.totalCharactersReceived,
required this.firstChatDate,
required this.mostRecentChatDate,
required this.chattingStreak,
required this.busiestDayOfWeek,
required this.busiestHourOfDay,
});

/// Total number of conversations in 2025.
final int totalConversations;

/// Total number of messages (user + assistant) in 2025.
final int totalMessages;

/// Total user messages sent.
final int totalUserMessages;

/// Total assistant responses received.
final int totalAssistantMessages;

/// The most used AI model.
final String favoriteModel;

/// Number of messages with the favorite model.
final int favoriteModelMessageCount;

/// Map of model name to message count.
final Map<String, int> modelUsageCounts;

/// The month with most activity (1-12).
final int busiestMonth;

/// Message count in the busiest month.
final int busiestMonthMessageCount;

/// Map of month (1-12) to message count.
final Map<int, int> monthlyMessageCounts;

/// Title of the longest conversation.
final String longestConversationTitle;

/// Message count in the longest conversation.
final int longestConversationMessageCount;

/// Average messages per conversation.
final double averageMessagesPerConversation;

/// Total characters typed by user.
final int totalCharactersTyped;

/// Total characters received from AI.
final int totalCharactersReceived;

/// Date of first chat in 2025.
final DateTime? firstChatDate;

/// Date of most recent chat.
final DateTime? mostRecentChatDate;

/// Longest streak of consecutive chatting days.
final int chattingStreak;

/// Most active day of week (1=Monday, 7=Sunday).
final int busiestDayOfWeek;

/// Most active hour of day (0-23).
final int busiestHourOfDay;

/// Whether there's enough data for a meaningful wrapped.
bool get hasEnoughData => totalConversations >= 1 && totalMessages >= 2;

/// Estimated words typed (rough estimate: chars / 5).
int get estimatedWordsTyped => (totalCharactersTyped / 5).round();

/// Estimated words read (rough estimate: chars / 5).
int get estimatedWordsRead => (totalCharactersReceived / 5).round();

/// Fun personality based on usage patterns.
String get chatPersonality {
if (totalMessages > 1000) return 'Power User';
if (totalMessages > 500) return 'Super Chatter';
if (totalMessages > 200) return 'Enthusiast';
if (totalMessages > 50) return 'Explorer';
if (totalMessages > 10) return 'Curious Mind';
return 'Getting Started';
}

/// Fun fact based on characters typed.
String get typingFunFact {
final pages = (totalCharactersTyped / 2000).round();
if (pages > 100) return "That's a novel's worth of typing!";
if (pages > 50) return "You've written a short book!";
if (pages > 20) return 'Enough to fill a magazine!';
if (pages > 5) return 'A solid essay collection!';
return 'A great start to your AI journey!';
}

/// Month name for busiest month.
String get busiestMonthName {
const months = [
'',
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
return months[busiestMonth.clamp(1, 12)];
}

/// Day name for busiest day.
String get busiestDayName {
const days = [
'',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
'Sunday',
];
return days[busiestDayOfWeek.clamp(1, 7)];
}

/// Hour formatted for display.
String get busiestHourFormatted {
if (busiestHourOfDay == 0) return '12 AM';
if (busiestHourOfDay < 12) return '$busiestHourOfDay AM';
if (busiestHourOfDay == 12) return '12 PM';
return '${busiestHourOfDay - 12} PM';
}

/// Top 3 models used.
List<MapEntry<String, int>> get topModels {
final sorted = modelUsageCounts.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
return sorted.take(3).toList();
}

/// Empty stats for when there's no data.
static const WrappedStats empty = WrappedStats(
totalConversations: 0,
totalMessages: 0,
totalUserMessages: 0,
totalAssistantMessages: 0,
favoriteModel: '',
favoriteModelMessageCount: 0,
modelUsageCounts: {},
busiestMonth: 1,
busiestMonthMessageCount: 0,
monthlyMessageCounts: {},
longestConversationTitle: '',
longestConversationMessageCount: 0,
averageMessagesPerConversation: 0,
totalCharactersTyped: 0,
totalCharactersReceived: 0,
firstChatDate: null,
mostRecentChatDate: null,
chattingStreak: 0,
busiestDayOfWeek: 1,
busiestHourOfDay: 12,
);
}
Loading