Skip to content

Commit 72fe87f

Browse files
committed
feat(analytics): add reading analytics feature with stats display and weekly chart
1 parent 9bacf17 commit 72fe87f

4 files changed

Lines changed: 446 additions & 0 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import 'package:flutter_riverpod/flutter_riverpod.dart';
2+
import 'package:readmigo/core/network/api_client.dart';
3+
4+
final analyticsServiceProvider = Provider<AnalyticsService>((ref) {
5+
return AnalyticsService(apiClient: ref.watch(apiClientProvider));
6+
});
7+
8+
class AnalyticsService {
9+
final ApiClient _apiClient;
10+
11+
AnalyticsService({required ApiClient apiClient}) : _apiClient = apiClient;
12+
13+
Future<Map<String, dynamic>> getOverview() async {
14+
final response = await _apiClient.get('/analytics/overview');
15+
return response.data as Map<String, dynamic>;
16+
}
17+
18+
Future<List<Map<String, dynamic>>> getDailyStats({int days = 7}) async {
19+
final response = await _apiClient.get(
20+
'/analytics/daily',
21+
queryParameters: {'days': days},
22+
);
23+
final data = response.data;
24+
if (data is List) {
25+
return data.map((e) => e as Map<String, dynamic>).toList();
26+
}
27+
return (data as Map)['stats'] as List<Map<String, dynamic>>? ?? [];
28+
}
29+
30+
Future<Map<String, dynamic>> getReadingTrend() async {
31+
final response = await _apiClient.get('/analytics/reading-trend');
32+
return response.data as Map<String, dynamic>;
33+
}
34+
35+
Future<Map<String, dynamic>> getInsights() async {
36+
final response = await _apiClient.get('/analytics/insights');
37+
return response.data as Map<String, dynamic>;
38+
}
39+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import 'package:flutter_riverpod/flutter_riverpod.dart';
2+
import 'package:readmigo/features/analytics/data/analytics_service.dart';
3+
4+
class AnalyticsState {
5+
final int totalReadingMinutes;
6+
final int booksCompleted;
7+
final int currentStreak;
8+
final int wordsLearned;
9+
final int totalSessions;
10+
final double avgSessionMinutes;
11+
final List<DailyReading> dailyStats;
12+
final bool isLoading;
13+
final String? error;
14+
15+
const AnalyticsState({
16+
this.totalReadingMinutes = 0,
17+
this.booksCompleted = 0,
18+
this.currentStreak = 0,
19+
this.wordsLearned = 0,
20+
this.totalSessions = 0,
21+
this.avgSessionMinutes = 0,
22+
this.dailyStats = const [],
23+
this.isLoading = false,
24+
this.error,
25+
});
26+
27+
String get totalReadingFormatted {
28+
final hours = totalReadingMinutes ~/ 60;
29+
final mins = totalReadingMinutes % 60;
30+
if (hours > 0) return '${hours}h ${mins}m';
31+
return '${mins}m';
32+
}
33+
34+
AnalyticsState copyWith({
35+
int? totalReadingMinutes,
36+
int? booksCompleted,
37+
int? currentStreak,
38+
int? wordsLearned,
39+
int? totalSessions,
40+
double? avgSessionMinutes,
41+
List<DailyReading>? dailyStats,
42+
bool? isLoading,
43+
String? Function()? error,
44+
}) {
45+
return AnalyticsState(
46+
totalReadingMinutes: totalReadingMinutes ?? this.totalReadingMinutes,
47+
booksCompleted: booksCompleted ?? this.booksCompleted,
48+
currentStreak: currentStreak ?? this.currentStreak,
49+
wordsLearned: wordsLearned ?? this.wordsLearned,
50+
totalSessions: totalSessions ?? this.totalSessions,
51+
avgSessionMinutes: avgSessionMinutes ?? this.avgSessionMinutes,
52+
dailyStats: dailyStats ?? this.dailyStats,
53+
isLoading: isLoading ?? this.isLoading,
54+
error: error != null ? error() : this.error,
55+
);
56+
}
57+
}
58+
59+
class DailyReading {
60+
final DateTime date;
61+
final int minutes;
62+
final int wordsLearned;
63+
64+
const DailyReading({
65+
required this.date,
66+
this.minutes = 0,
67+
this.wordsLearned = 0,
68+
});
69+
70+
factory DailyReading.fromJson(Map<String, dynamic> json) {
71+
return DailyReading(
72+
date: DateTime.parse(json['date'] as String),
73+
minutes: json['minutes'] as int? ?? json['readingMinutes'] as int? ?? 0,
74+
wordsLearned: json['wordsLearned'] as int? ?? 0,
75+
);
76+
}
77+
}
78+
79+
class AnalyticsNotifier extends Notifier<AnalyticsState> {
80+
@override
81+
AnalyticsState build() => const AnalyticsState();
82+
83+
AnalyticsService get _service => ref.read(analyticsServiceProvider);
84+
85+
Future<void> loadAnalytics() async {
86+
state = state.copyWith(isLoading: true, error: () => null);
87+
try {
88+
final results = await Future.wait([
89+
_service.getOverview(),
90+
_service.getDailyStats(),
91+
]);
92+
final overview = results[0] as Map<String, dynamic>;
93+
final daily = results[1] as List<Map<String, dynamic>>;
94+
95+
state = state.copyWith(
96+
totalReadingMinutes: overview['totalReadingMinutes'] as int? ?? 0,
97+
booksCompleted: overview['booksCompleted'] as int? ?? 0,
98+
currentStreak:
99+
overview['currentStreak'] as int? ??
100+
overview['streakDays'] as int? ??
101+
0,
102+
wordsLearned:
103+
overview['wordsLearned'] as int? ??
104+
overview['totalWordsLearned'] as int? ??
105+
0,
106+
totalSessions: overview['totalSessions'] as int? ?? 0,
107+
avgSessionMinutes:
108+
(overview['avgSessionMinutes'] as num?)?.toDouble() ?? 0,
109+
dailyStats: daily.map((e) => DailyReading.fromJson(e)).toList(),
110+
isLoading: false,
111+
);
112+
} catch (e) {
113+
state = state.copyWith(isLoading: false, error: () => e.toString());
114+
}
115+
}
116+
}
117+
118+
final analyticsProvider =
119+
NotifierProvider<AnalyticsNotifier, AnalyticsState>(AnalyticsNotifier.new);

0 commit comments

Comments
 (0)