Skip to content

feat: add statistics screen to Flutter app (PCA + K-Means visualization) - #25

Merged
ASSONDJI merged 1 commit into
mainfrom
feature/mobile-statistics-screen
Jul 9, 2026
Merged

feat: add statistics screen to Flutter app (PCA + K-Means visualization)#25
ASSONDJI merged 1 commit into
mainfrom
feature/mobile-statistics-screen

Conversation

@ASSONDJI

@ASSONDJI ASSONDJI commented Jul 9, 2026

Copy link
Copy Markdown
Owner

Connects recommendation-service's /pca and /clustering endpoints to a new scatter-plot screen, accessible from the languages list. Only tonally-annotated words (nbSyllabes/tone1/tone2) can be projected — currently only Yemba has this annotation — so the repository throws a NotEnoughDataException with a clear message for languages without enough annotated words, rather than crashing.

Also fixes a port collision between user-service and recommendation-service (both defaulted to 8086, undetected until both services ran simultaneously for the first time). user-service moved to 8087.

Verified manually end to end: all 7 services register on Eureka, PCA + clustering scatter plot renders correctly for Yemba, tapping a point identifies the word and its cluster.

Summary by CodeRabbit

  • New Features
    • Added a new statistics view for languages, accessible from each language card.
    • Users can now see a scatter plot of word statistics and tap points to inspect details like the word, translation, and cluster.
    • The statistics screen now shows clear loading, error, and empty-data messaging when available data is insufficient.

Connects recommendation-service's /pca and /clustering endpoints to
a new scatter-plot screen, accessible from the languages list. Only
tonally-annotated words (nbSyllabes/tone1/tone2) can be projected —
currently only Yemba has this annotation — so the repository throws
a NotEnoughDataException with a clear message for languages without
enough annotated words, rather than crashing.

Also fixes a port collision between user-service and
recommendation-service (both defaulted to 8086, undetected until
both services ran simultaneously for the first time). user-service
moved to 8087.

Verified manually end to end: all 7 services register on Eureka,
PCA + clustering scatter plot renders correctly for Yemba, tapping
a point identifies the word and its cluster.
@ASSONDJI ASSONDJI self-assigned this Jul 9, 2026
@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a new mobile "statistics" feature: domain models (StatisticsPoint, WordWithTones), a repository fetching PCA/clustering data via API and combining results, Riverpod providers, a scatter-plot screen with tap selection, and navigation entry points (router route and languages screen button). Also bumps user-service application port from 8086 to 8087.

Changes

Mobile Statistics Feature

Layer / File(s) Summary
Domain models
mobile/lib/features/statistics/domain/statistics_point.dart, mobile/lib/features/statistics/domain/word_model.dart
Adds immutable StatisticsPoint and WordWithTones models, with WordWithTones.fromJsonIfAnnotated returning null when tone annotations are missing.
Statistics repository
mobile/lib/features/statistics/data/statistics_repository.dart
Implements StatisticsRepository.loadWordStatistics fetching words, filtering annotated entries, validating minimum count (throwing NotEnoughDataException otherwise), building features, and calling PCA/clustering endpoints concurrently to build StatisticsPoint results.
Riverpod providers
mobile/lib/features/statistics/presentation/statistics_providers.dart
Adds statisticsRepositoryProvider and wordStatisticsProvider (FutureProvider.family) exposing statistics data to the UI.
Statistics screen UI
mobile/lib/features/statistics/presentation/statistics_screen.dart
Adds StatisticsScreen with loading/error/success states, _StatisticsBody selection UX, tap-to-select scatter plot, coordinate projection helpers, and _ScatterPainter.
Navigation wiring
mobile/lib/core/router/app_router.dart, mobile/lib/features/languages/presentation/languages_screen.dart
Registers /statistics/:languageId route and adds a statistics icon button on language list items to navigate with languageId/languageName.

Estimated code review effort: 3 (Moderate) | ~25 minutes

User Service Port Configuration

Layer / File(s) Summary
Server port change
services/user-service/src/main/resources/application.yml
Changes server.port from 8086 to 8087.

Estimated code review effort: 1 (Trivial) | ~2 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant LanguagesScreen
  participant StatisticsScreen
  participant StatisticsRepository
  participant API

  User->>LanguagesScreen: tap statistics icon
  LanguagesScreen->>StatisticsScreen: navigate with languageId, languageName
  StatisticsScreen->>StatisticsRepository: loadWordStatistics(languageId)
  StatisticsRepository->>API: fetch words for language
  StatisticsRepository->>API: request PCA + clustering
  API-->>StatisticsRepository: pca points, cluster assignments
  StatisticsRepository-->>StatisticsScreen: List<StatisticsPoint>
  StatisticsScreen-->>User: render scatter plot / error message
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: a new Flutter statistics screen with PCA and K-Means visualization.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/mobile-statistics-screen

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@ASSONDJI
ASSONDJI merged commit 251baa1 into main Jul 9, 2026
6 of 7 checks passed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
mobile/lib/features/statistics/data/statistics_repository.dart (1)

56-58: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Null-safe casts on clustering response fields.

entry['word_id'] as String and entry['cluster'] as int will throw if either field is null in the clustering response. Consider using null-safe casts with fallbacks.

🛡️ Proposed fix
-    for (final entry in clusterData) entry['word_id'] as String: entry['cluster'] as int,
+    for (final entry in clusterData)
+      (entry['word_id'] as String?) ?? '': (entry['cluster'] as int?) ?? 0,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mobile/lib/features/statistics/data/statistics_repository.dart` around lines
56 - 58, The cluster mapping in statistics_repository.dart is using unsafe casts
for the clustering response fields, so update the logic around clusterByWordId
to handle missing values safely. In the StatisticsRepository code that builds
the map from clusterData, replace entry['word_id'] and entry['cluster']
assumptions with null-safe conversion and sensible fallbacks so null or
malformed entries are skipped or defaulted instead of throwing.
mobile/lib/features/statistics/domain/word_model.dart (1)

29-36: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Guard against null id/word/translation from the API.

nbSyllabes, tone1, and tone2 are null-checked, but id, word, and translation are cast with as String without null checks. If the backend ever omits one of these fields, this will throw a TypeError instead of returning null.

🛡️ Proposed fix
     return WordWithTones(
-      id: json['id'] as String,
-      word: json['word'] as String,
-      translation: json['translation'] as String,
+      id: json['id'] as String? ?? '',
+      word: json['word'] as String? ?? '',
+      translation: json['translation'] as String? ?? '',
       nbSyllabes: nbSyllabes,
       tone1: tone1,
       tone2: tone2,
     );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mobile/lib/features/statistics/domain/word_model.dart` around lines 29 - 36,
The WordWithTones JSON parsing path in the WordModel factory is missing null
guards for id, word, and translation, so an omitted API field can throw a
TypeError instead of yielding null. Update the parsing logic in the
WordModel/WordWithTones mapping so these fields are checked safely like
nbSyllabes, tone1, and tone2, and only construct the model when all required
values are present.
mobile/lib/features/statistics/presentation/statistics_screen.dart (1)

120-131: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

_bounds and _project are recomputed on every tap and every paint frame.

_bounds iterates all points four times (two reduces for x, two for y). For large datasets this is minor, but the bounds are also recomputed separately in _handleTap and _ScatterPainter.paint. Consider computing bounds once and passing them to both.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mobile/lib/features/statistics/presentation/statistics_screen.dart` around
lines 120 - 131, The statistics screen recomputes point bounds separately in
both `_handleTap` and `_ScatterPainter.paint`, and `_bounds` itself scans the
list multiple times, so the same work is repeated unnecessarily. Compute the
bounds once per dataset in the statistics screen flow, reuse the result for tap
handling and painting, and pass the cached min/max values into `_project`
instead of recalculating them in each call.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@mobile/lib/features/statistics/data/statistics_repository.dart`:
- Around line 61-72: The StatisticsRepository point mapping currently
force-unwraps wordById[wordId] inside the PCA points transform, which can crash
if the PCA response and annotated words list are out of sync. Update the mapping
in the repository method that builds StatisticsPoint objects to safely look up
the word by word_id, and either skip unmatched points or handle them gracefully
before constructing the point. Keep the clusterByWordId fallback behavior, but
remove the null-check exception risk from the word lookup.

In `@mobile/lib/features/statistics/presentation/statistics_screen.dart`:
- Around line 94-117: The tap hit-testing in _handleTap is using the wrong
render box, so the projected point coordinates do not line up with the painted
scatter plot. Update _StatisticsBody/_handleTap to measure the actual
GestureDetector or CustomPaint area instead of context.findRenderObject() from
the Column, and use that render box size and offset for _project and
localPosition comparisons. A GlobalKey on the GestureDetector is a good way to
retrieve the correct render object and keep the coordinate space consistent.

---

Nitpick comments:
In `@mobile/lib/features/statistics/data/statistics_repository.dart`:
- Around line 56-58: The cluster mapping in statistics_repository.dart is using
unsafe casts for the clustering response fields, so update the logic around
clusterByWordId to handle missing values safely. In the StatisticsRepository
code that builds the map from clusterData, replace entry['word_id'] and
entry['cluster'] assumptions with null-safe conversion and sensible fallbacks so
null or malformed entries are skipped or defaulted instead of throwing.

In `@mobile/lib/features/statistics/domain/word_model.dart`:
- Around line 29-36: The WordWithTones JSON parsing path in the WordModel
factory is missing null guards for id, word, and translation, so an omitted API
field can throw a TypeError instead of yielding null. Update the parsing logic
in the WordModel/WordWithTones mapping so these fields are checked safely like
nbSyllabes, tone1, and tone2, and only construct the model when all required
values are present.

In `@mobile/lib/features/statistics/presentation/statistics_screen.dart`:
- Around line 120-131: The statistics screen recomputes point bounds separately
in both `_handleTap` and `_ScatterPainter.paint`, and `_bounds` itself scans the
list multiple times, so the same work is repeated unnecessarily. Compute the
bounds once per dataset in the statistics screen flow, reuse the result for tap
handling and painting, and pass the cached min/max values into `_project`
instead of recalculating them in each call.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8ddd6368-2ce8-489b-bf03-e5b693af0692

📥 Commits

Reviewing files that changed from the base of the PR and between c2fc13a and bab3c5b.

📒 Files selected for processing (8)
  • mobile/lib/core/router/app_router.dart
  • mobile/lib/features/languages/presentation/languages_screen.dart
  • mobile/lib/features/statistics/data/statistics_repository.dart
  • mobile/lib/features/statistics/domain/statistics_point.dart
  • mobile/lib/features/statistics/domain/word_model.dart
  • mobile/lib/features/statistics/presentation/statistics_providers.dart
  • mobile/lib/features/statistics/presentation/statistics_screen.dart
  • services/user-service/src/main/resources/application.yml

Comment on lines +61 to +72
return (pcaData['points'] as List).cast<Map<String, dynamic>>().map((json) {
final wordId = json['word_id'] as String;
final word = wordById[wordId]!;
return StatisticsPoint(
wordId: wordId,
word: word.word,
translation: word.translation,
x: (json['x'] as num).toDouble(),
y: (json['y'] as num).toDouble(),
cluster: clusterByWordId[wordId] ?? 0,
);
}).toList();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard against PCA/clustering word_id mismatch with force unwrap.

Line 63 uses wordById[wordId]! — if the PCA response contains a word_id not present in the annotated words list (e.g., backend data inconsistency or stale cache), this will throw a null check exception and crash the screen. Consider filtering out unmatched PCA points or using a safe lookup.

🛡️ Proposed fix
-    return (pcaData['points'] as List).cast<Map<String, dynamic>>().map((json) {
-      final wordId = json['word_id'] as String;
-      final word = wordById[wordId]!;
-      return StatisticsPoint(
+    return (pcaData['points'] as List).cast<Map<String, dynamic>>()
+        .map((json) {
+      final wordId = json['word_id'] as String;
+      final word = wordById[wordId];
+      if (word == null) return null;
+      return StatisticsPoint(
         wordId: wordId,
         word: word.word,
         translation: word.translation,
         x: (json['x'] as num).toDouble(),
         y: (json['y'] as num).toDouble(),
         cluster: clusterByWordId[wordId] ?? 0,
       );
-    }).toList();
+    })
+        .whereType<StatisticsPoint>()
+        .toList();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return (pcaData['points'] as List).cast<Map<String, dynamic>>().map((json) {
final wordId = json['word_id'] as String;
final word = wordById[wordId]!;
return StatisticsPoint(
wordId: wordId,
word: word.word,
translation: word.translation,
x: (json['x'] as num).toDouble(),
y: (json['y'] as num).toDouble(),
cluster: clusterByWordId[wordId] ?? 0,
);
}).toList();
return (pcaData['points'] as List).cast<Map<String, dynamic>>()
.map((json) {
final wordId = json['word_id'] as String;
final word = wordById[wordId];
if (word == null) return null;
return StatisticsPoint(
wordId: wordId,
word: word.word,
translation: word.translation,
x: (json['x'] as num).toDouble(),
y: (json['y'] as num).toDouble(),
cluster: clusterByWordId[wordId] ?? 0,
);
})
.whereType<StatisticsPoint>()
.toList();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mobile/lib/features/statistics/data/statistics_repository.dart` around lines
61 - 72, The StatisticsRepository point mapping currently force-unwraps
wordById[wordId] inside the PCA points transform, which can crash if the PCA
response and annotated words list are out of sync. Update the mapping in the
repository method that builds StatisticsPoint objects to safely look up the word
by word_id, and either skip unmatched points or handle them gracefully before
constructing the point. Keep the clusterByWordId fallback behavior, but remove
the null-check exception risk from the word lookup.

Comment on lines +94 to +117
void _handleTap(TapUpDetails details) {
final box = context.findRenderObject() as RenderBox?;
if (box == null || widget.points.isEmpty) return;

final size = box.size;
final (minX, maxX, minY, maxY) = _bounds(widget.points);
const padding = 24.0;

StatisticsPoint? closest;
double closestDistance = double.infinity;

for (final point in widget.points) {
final offset = _project(point, minX, maxX, minY, maxY, size, padding);
final distance = (details.localPosition - offset).distance;
if (distance < closestDistance) {
closestDistance = distance;
closest = point;
}
}

if (closest != null && closestDistance < 30) {
setState(() => _selected = closest);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Tap detection uses wrong render box — coordinates won't match the painted scatter plot.

context.findRenderObject() on line 95 returns the render box of the _StatisticsBodyState's build root (the Column), not the GestureDetector/CustomPaint. However, details.localPosition is relative to the GestureDetector. Since the CustomPaint is nested inside Expanded + Padding within the Column, box.size will be the full body size (including the header text and bottom card), not the paint area. This means _project will map points to a different coordinate space than where they're actually drawn, making tap selection inaccurate or non-functional.

🐛 Proposed fix using a GlobalKey on the GestureDetector
 class _StatisticsBodyState extends State<_StatisticsBody> {
   StatisticsPoint? _selected;
+  final _gestureKey = GlobalKey();

   static const _palette = [
@@
           child: GestureDetector(
+            key: _gestureKey,
             onTapUp: _handleTap,
@@
   void _handleTap(TapUpDetails details) {
-    final box = context.findRenderObject() as RenderBox?;
+    final box = _gestureKey.currentContext?.findRenderObject() as RenderBox?;
     if (box == null || widget.points.isEmpty) return;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
void _handleTap(TapUpDetails details) {
final box = context.findRenderObject() as RenderBox?;
if (box == null || widget.points.isEmpty) return;
final size = box.size;
final (minX, maxX, minY, maxY) = _bounds(widget.points);
const padding = 24.0;
StatisticsPoint? closest;
double closestDistance = double.infinity;
for (final point in widget.points) {
final offset = _project(point, minX, maxX, minY, maxY, size, padding);
final distance = (details.localPosition - offset).distance;
if (distance < closestDistance) {
closestDistance = distance;
closest = point;
}
}
if (closest != null && closestDistance < 30) {
setState(() => _selected = closest);
}
}
void _handleTap(TapUpDetails details) {
final box = _gestureKey.currentContext?.findRenderObject() as RenderBox?;
if (box == null || widget.points.isEmpty) return;
final size = box.size;
final (minX, maxX, minY, maxY) = _bounds(widget.points);
const padding = 24.0;
StatisticsPoint? closest;
double closestDistance = double.infinity;
for (final point in widget.points) {
final offset = _project(point, minX, maxX, minY, maxY, size, padding);
final distance = (details.localPosition - offset).distance;
if (distance < closestDistance) {
closestDistance = distance;
closest = point;
}
}
if (closest != null && closestDistance < 30) {
setState(() => _selected = closest);
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mobile/lib/features/statistics/presentation/statistics_screen.dart` around
lines 94 - 117, The tap hit-testing in _handleTap is using the wrong render box,
so the projected point coordinates do not line up with the painted scatter plot.
Update _StatisticsBody/_handleTap to measure the actual GestureDetector or
CustomPaint area instead of context.findRenderObject() from the Column, and use
that render box size and offset for _project and localPosition comparisons. A
GlobalKey on the GestureDetector is a good way to retrieve the correct render
object and keep the coordinate space consistent.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant