insightreader_sdk 1.0.0
insightreader_sdk: ^1.0.0 copied to clipboard
Personalisation for Flutter news apps — reading tracking, time-of-day category insights, streaks, curated notifications, personalised feeds and Gemini AI summaries.
Insightreader SDK for Flutter #
A personalisation SDK for news apps. It learns what your readers read and when, keeps their reading streak, schedules curated local notifications, renders personalised article feeds, and generates AI summaries, key takeaways and 5W1H breakdowns.
This is the Flutter counterpart of the native iOS InsightreaderSDK. Behaviour,
naming and business rules follow that implementation; the AI layer uses Gemini,
following the Android SDK's approach, because Apple's on-device Foundation
Models are not reachable from Dart.
Contents #
- Installation
- Initialization
- Configuration
- Permissions
- Tracking content
- Generating summaries
- Key takeaways
- 5W1H extraction
- Batch processing
- Notifications
- Personalisation APIs
- Article feeds
- Models and output structures
- Error handling
- AI and Gemini configuration
- Analytics forwarding
- Complete example
- Behaviour notes
1. Installation #
dependencies:
insightreader_sdk: ^1.0.0
flutter pub get
Minimum versions: Flutter 3.32, Dart 3.8, iOS 12, Android API 21.
Android #
Two pieces of setup are required — the SDK's scheduled notifications do not work without them.
1. Enable core library desugaring in android/app/build.gradle.kts. The
notification plugin enables it in its own module, and AGP fails the build unless
the application module opts in too:
android {
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}
2. Declare the notification receivers in
android/app/src/main/AndroidManifest.xml. Without
ScheduledNotificationReceiver, AlarmManager fires at a receiver that does not
exist and scheduled notifications silently never appear — no error, nothing
in the log, just no notification:
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<application ...>
<receiver
android:exported="false"
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
<receiver
android:exported="false"
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>
<action android:name="android.intent.action.QUICKBOOT_POWERON"/>
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON"/>
</intent-filter>
</receiver>
</application>
The boot receiver re-arms schedules after a restart, which iOS gets for free from its repeating calendar triggers.
INTERNET and POST_NOTIFICATIONS merge in from the plugins automatically — you
do not need to declare them. No exact-alarm permission is needed either: the SDK
deliberately uses inexact alarms, because Google Play restricts
SCHEDULE_EXACT_ALARM to alarm-clock and calendar apps.
JDK. Gradle and AGP must run on a JDK they support — JDK 17 or 21. If
flutter build fails with a bare version number such as
What went wrong: 25.0.2, that is AGP rejecting the JDK it was handed. Point
Flutter at a supported one:
flutter config --jdk-dir "/path/to/jdk-17/Contents/Home"
iOS #
No extra setup for notifications — the SDK requests authorization at runtime. If you enable AI caching, follow the Firebase setup in section 15.
Licensing #
The SDK performs no runtime licence check — initialize() succeeds for any host
app. Use in a distributed or publicly available application requires a written
commercial agreement with Mediology Software; see LICENSE.
2. Initialization #
Call initialize() once, before runApp.
import 'package:flutter/material.dart';
import 'package:insightreader_sdk/insightreader_sdk.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await InsightreaderSdk.instance.initialize(
configuration: const InsightreaderConfiguration(
gemini: GeminiConfiguration(
apiKey: String.fromEnvironment('GEMINI_API_KEY'),
),
),
);
runApp(const MyApp());
}
Every field has a working default, so const InsightreaderConfiguration() alone
is a valid call.
Calling initialize() twice is safe: a second call while the first is running
awaits it, and a call after success is a no-op.
If you want the app to start even when initialization fails, catch the error — the SDK's widgets degrade to empty rather than throwing:
try {
await InsightreaderSdk.instance.initialize(configuration: config);
} on InsightreaderException catch (e) {
debugPrint('Insightreader unavailable: ${e.message}');
}
3. Configuration #
Everything the SDK needs arrives through InsightreaderConfiguration. Nothing
environment-specific is compiled in.
Persistence #
| Field | Default | Purpose |
|---|---|---|
maxStoredEvents |
10000 |
Reading events retained before the oldest are pruned. |
analyticsWindowDays |
90 |
How far back category queries look. 0 disables windowing and ranks over all time. |
Notifications #
| Field | Default | Purpose |
|---|---|---|
notificationsEnabled |
true |
Master default. Reader preferences override it. |
streakNotificationsEnabled |
true |
Whether the streak-break reminder is armed. |
streakWarningHour |
16 |
Local hour the streak reminder fires. |
segmentTriggerHours |
07/13/18/21 |
Local hour each briefing fires, keyed by segment. |
streakWarningContentBuilder |
null |
Custom streak-reminder copy. |
customNotificationMessages |
{} |
Per-segment title/body overrides. |
androidBriefingChannelId / Name |
insightreader_briefings |
Android channel for briefings. |
androidStreakChannelId / Name |
insightreader_streak |
Android channel for streak reminders. |
androidNotificationIcon |
@mipmap/ic_launcher |
Status-bar icon. |
AI #
| Field | Default | Purpose |
|---|---|---|
aiSummarizationEnabled |
true |
Master switch for all AI features. |
gemini |
GeminiConfiguration() |
Key, model, endpoint and limits. See section 15. |
aiCache |
AiCacheConfiguration() |
Firestore response cache. See section 15. |
Networking #
| Field | Default | Purpose |
|---|---|---|
feedRequestTimeout |
30s |
Timeout for feed requests. |
feedRequestHeaders |
{} |
Headers added to every feed request — auth tokens, tenancy headers. |
Analytics and callbacks #
| Field | Default | Purpose |
|---|---|---|
analyticsEnabled |
true |
Set false to suppress every event. |
onStreakMilestone |
null |
Called on 3, 7, 14, 30, 60, 100 and 365-day streaks. |
InsightreaderConfiguration(
analyticsWindowDays: 30,
streakWarningHour: 20,
customNotificationMessages: const {
'morning': SegmentNotificationContent(title: 'Rise and shine ☕️'),
'night': SegmentNotificationContent(body: 'One more story before bed?'),
},
onStreakMilestone: (days) => showCelebration('🔥 $days-day streak!'),
gemini: const GeminiConfiguration(
apiKey: String.fromEnvironment('GEMINI_API_KEY'),
),
)
4. Permissions #
The SDK asks for notification permission when you first schedule notifications, so you rarely need to request it yourself:
await InsightreaderSdk.instance.scheduleNotifications();
To show your own explainer first, request it explicitly:
try {
await InsightreaderSdk.instance.requestNotificationPermission();
} on InsightreaderException catch (e) {
if (e.code == InsightreaderErrorCode.notificationPermissionDenied) {
showSettingsHint();
}
}
5. Tracking content #
One call powers everything else — categories, recommendations, streaks, notification copy and feed ranking.
InsightreaderSdk.instance.trackArticleRead(
articleId: article.id,
category: 'Technology', // display name
categoryId: '101', // stable id; defaults to the name if omitted
readDuration: 240, // seconds
);
This is fire-and-forget: failures are reported to analytics and swallowed, so tracking can never break a reading session. Use the async form when you need the outcome:
try {
await InsightreaderSdk.instance.trackArticleReadAsync(
articleId: article.id,
category: 'Technology',
categoryId: '101',
readDuration: stopwatch.elapsed.inSeconds,
);
} on InsightreaderException catch (e) {
debugPrint('Tracking failed: ${e.message}');
}
articleId and category must both be non-empty; a negative readDuration is
clamped to zero.
Call it when the reader leaves an article, so you can pass a real duration — duration carries 40% of a category's engagement score.
6. Generating summaries #
final summary = await InsightreaderSdk.instance.summarizeHtml(
article.html,
articleId: article.id,
);
print(summary.title); // model headline, or the parsed <title>
print(summary.summary); // 2–4 sentences
print(summary.keyPoints); // 3–5 takeaways
print(summary.readingTime); // whole minutes, at 238 wpm
print(summary.topics); // detected topics
HTML is parsed, scripts and styles stripped, entities decoded, and the text
truncated to maxInputCharacters (6000, matching iOS) before the model sees it.
Reading time is measured on the full text, not the truncated slice.
Passing articleId enables caching: with Firestore caching on, the model is
called at most once per article across your entire user base.
For text you have already extracted:
final summary = await InsightreaderSdk.instance.summarizeText(
plainText,
title: article.title,
articleId: article.id,
);
7. Key takeaways #
Key takeaways come back on the same call, as SummaryResult.keyPoints — 3 to 5
bullet-ready strings.
final summary = await InsightreaderSdk.instance.summarizeHtml(article.html);
Column(
children: [
for (final point in summary.keyPoints)
ListTile(leading: const Icon(Icons.circle, size: 8), title: Text(point)),
],
)
8. 5W1H extraction #
The classic newsroom framework: Who, What, When, Where, Why and How.
final context = await InsightreaderSdk.instance.extractFiveWOneH(
article.html,
articleId: article.id,
);
print('Who: ${context.who.join(", ")}');
print('What: ${context.what}');
print('When: ${context.when.join(", ")}');
print('Where: ${context.location.join(", ")}');
print('Why: ${context.why}');
print('How: ${context.how}');
Fields come back empty when the article does not answer them — the model is
instructed not to invent. Check context.hasContent before rendering, and skip
empty dimensions rather than showing blank cards.
9. Batch processing #
Summarise up to ten stories in one call.
final results = await InsightreaderSdk.instance.summarizeHtmlBatch(
stories.map((s) => s.html).toList(),
articleIds: stories.map((s) => s.id).toList(),
);
for (final result in results) {
if (result.succeeded) {
print('[${result.index}] ${result.summary!.summary}');
} else {
print('[${result.index}] Failed: ${result.errorDescription}');
}
}
- The returned list is always the same length and order as the input.
- A failure is captured inside its own
BatchSummaryResult— one bad story never aborts the rest. - More than
batchLimit(10) items throwsInsightreaderErrorCode.batchLimitExceeded. articleIds, when given, must be the same length as the HTML list.- At most
maxConcurrentRequests(4) calls run at once, keeping you inside Gemini's rate limits.
10. Notifications #
Four daily briefings, each built around the reader's most-read category for that time of day, plus a streak-break reminder.
await InsightreaderSdk.instance.scheduleNotifications();
Safe to call on every launch. It requests permission, schedules the enabled briefings, and re-arms the streak reminder.
Runtime controls #
final sdk = InsightreaderSdk.instance;
await sdk.enableNotifications(); // on + schedule immediately
await sdk.disableNotifications(); // cancel everything the SDK owns
await sdk.setDailyNotificationsEnabled(false); // all four briefings off
await sdk.setSegmentNotificationEnabled(TimeSegment.night, false); // just one
await sdk.setStreakNotificationsEnabled(false); // just the streak reminder
await sdk.refreshNotificationContent(); // regenerate from latest behaviour
The three switches are independent: turning briefings off leaves the streak reminder armed, and vice versa.
Reading current preferences #
final prefs = InsightreaderSdk.instance.notificationPreferences();
prefs.notificationsEnabled; // master switch
prefs.isEnabled(TimeSegment.morning); // one briefing
prefs.streakWarningEnabled; // streak reminder
These are what the reader last chose, not what you passed in configuration. A reader who switched notifications off stays off across launches, even though your app re-initializes and re-schedules on every start.
Or use the ready-made settings screen:
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const InsightreaderSettingsScreen()),
);
Custom copy #
InsightreaderSdk.instance.setCustomNotificationMessages({
'morning': const SegmentNotificationContent(title: 'Rise and shine ☕️'),
});
await InsightreaderSdk.instance.scheduleNotifications(); // apply it
A segment with no entry keeps the SDK's defaults entirely; within an entry, a
null title or body keeps just that field's default.
InsightreaderSdk.instance.setStreakNotificationContent(
(streak) => StreakWarningContent(
title: 'Keep your $streak-day streak!',
body: 'Open the app now to lock it in.',
),
);
Handling taps #
The SDK never navigates on its own.
// A tap while the app is running
InsightreaderSdk.instance.onNotificationTap.listen((tap) {
switch (tap.type) {
case InsightreaderNotificationType.briefing:
router.openCategory(tap.categoryId);
case InsightreaderNotificationType.streakWarning:
case InsightreaderNotificationType.streakWarningPreview:
router.openStreakScreen();
}
});
// A tap that launched the app — read once after initialize()
final launch = InsightreaderSdk.instance.launchNotification;
if (launch != null) router.openCategory(launch.categoryId);
Previewing #
await InsightreaderSdk.instance.previewStreakWarningNotification(
currentStreak: 14,
);
Fires in about five seconds using a separate id, so it never disturbs real scheduling. QA only.
11. Personalisation APIs #
final sdk = InsightreaderSdk.instance;
// All-time
final top = await sdk.mostReadCategory();
final topFive = await sdk.topCategories(limit: 5);
// By time of day
final morningTop = await sdk.mostReadCategoryForSegment(TimeSegment.morning);
final eveningFive = await sdk.topCategoriesForSegment(
TimeSegment.evening,
limit: 5,
);
// Blended recommendation — 70% time-of-day affinity, 30% overall popularity
final recommended = await sdk.recommendedCategories(limit: 5);
// Streak and goal
final streak = await sdk.readingStreak();
await sdk.setDailyReadingGoal(3); // 0 removes the goal
// Diagnostics
final score = await sdk.engagementScore('101');
final trends = await sdk.readingTrends();
final events = await sdk.totalTrackedEvents();
// Privacy
await sdk.clearAllData();
// The current time-of-day bucket
final segment = sdk.currentTimeSegment;
Streak surfaces #
Two of them, and they are not interchangeable.
The inline badge is built into InsightreaderArticleFeed's header — enabled
with showStreak: true. It is decoration and cannot be tapped. It reports
insights_shown_gni.
The floating pill is a widget you place yourself, and it is tappable. Use it when the streak should be a way into a streak or insights screen:
Stack(
children: [
content,
Positioned(
right: 16,
bottom: 24,
child: InsightreaderStreakPill(
streak: (await sdk.readingStreak()).currentStreak,
onTap: () => router.openStreakScreen(),
),
),
],
)
It renders nothing when streak is zero or less, so it can be mounted
unconditionally. It reports insights_floating_shown_gni once per appearance
(hidden → visible, not per rebuild) and insights_floating_tapped_gni on tap.
Styling is overridable via backgroundColor, foregroundColor, fontSize,
icon and labelBuilder; the defaults follow the ambient ColorScheme.
Time segments #
| Segment | Hours | Default briefing |
|---|---|---|
morning |
05:00 – 11:59 | 07:00 |
afternoon |
12:00 – 16:59 | 13:00 |
evening |
17:00 – 20:59 | 18:00 |
night |
21:00 – 04:59 | 21:00 |
12. Article feeds #
Personalised section #
InsightreaderArticleFeed(
source: const FeedDataSource.url('https://api.example.com/feed'),
configuration: const ArticleMappingConfig(
mainKey: 'articles',
articleIdKey: 'id',
titleKey: 'headline',
descriptionKey: 'standfirst',
imageUrlKey: 'urlToImage',
dateKey: 'publishedAt',
categoryKey: 'section',
),
maxArticles: 8,
layout: FeedLayout.heroThenLeftImageListing,
headerText: 'For you',
onArticleTap: (context) => router.push(ArticleScreen(context.article)),
onHeaderTap: () => router.push(const AllStoriesScreen()),
)
The feed walks the reader's categories in rank order, each contributing its matching articles before the next is considered, then pads any shortfall with unmatched articles so the section always fills its slots. Nothing is filtered out — no article is lost to personalisation.
Layouts #
FeedLayout |
Shape |
|---|---|
heroThenLeftImageListing |
Hero card, then rows with the thumbnail on the left. Default. |
heroThenRightImageListing |
Hero card, then rows with the thumbnail on the right. |
numberListing |
Numbered list, no images. The "Trending" look. |
horizontalImageThenTitleListing |
Horizontal strip of gradient image cards. |
Data sources #
// Pre-fetched JSON — you did the networking
FeedDataSource.data(responseBody)
// A URL the SDK fetches
FeedDataSource.url('https://api.example.com/feed')
// The reader's Nth most-read category, resolved automatically
FeedDataSource.category(
urlStrategy: CategoryFeedUrlStrategy.custom(
(categoryId, page) => 'https://api.example.com/category/$categoryId/$page',
),
mostViewedRank: 1, // 1 = most-viewed
allTime: true, // false uses the time-of-day recommendation
)
Mapping your JSON #
ArticleMappingConfig finds the articles array recursively, so nesting depth
does not matter. Categories accept a plain string or a list of objects; images
accept a string, an object, or a list of objects.
const ArticleMappingConfig(
mainKey: 'posts',
articleIdKey: 'id',
titleKey: 'headline',
categoryKey: 'categories',
categoryNameKey: 'name', // categories: [{"id": 6, "name": "Sports"}]
categoryIdKey: 'id',
imageUrlKey: 'images',
thumbnailImageKey: 'thumbImage',
dateKey: 'published_at',
urlKey: 'permalink',
slugKey: 'slug',
)
Infinite scrolling #
PaginatedArticleFeed(
configuration: PaginatedFeedConfiguration.url(
urlStrategy: PaginationUrlStrategy.template(
'https://api.example.com/posts/{page}/0/30',
),
startPage: 0,
mappingConfig: mapping,
),
onArticleTap: (context) => router.push(ArticleScreen(context.article)),
)
URL strategies: template, afterPathSegment, queryParameter, custom.
To paginate within the reader's top category, use
PaginatedFeedConfiguration.category(...) — the category is resolved once and
reused for every page.
Theming #
InsightreaderArticleFeed(
// ...
theme: FeedTheme.custom(
FeedStyle(
hero: HeroCardStyle(
backgroundColor: AdaptiveColor.mode(
light: Colors.white,
dark: const Color(0xFF101010),
),
categoryBadgeColor: AdaptiveColor.hex('#D32F2F'),
cornerRadius: 12,
),
list: const ListCardStyle(cornerRadius: 8),
containerGradientColors: [AdaptiveColor.hex('#FBF7FF')],
containerPadding: 20,
listCardSpacing: 8,
),
),
)
AdaptiveColor resolves per light/dark mode. FeedTheme.standard() gives the
SDK's branded look with no configuration.
Driving the feed yourself #
Every widget is a thin shell over a ChangeNotifier. To build your own UI:
final controller = FeedController(
source: const FeedDataSource.url(url),
configuration: mapping,
maxArticles: 10,
);
await controller.load();
controller.state; // FeedLoadState
controller.articles; // List<FeedArticle>, ranked
controller.error; // FeedError?
AI insights UI #
// Built-in sparkles button plus sheet
InsightreaderAiInsightsButton(
content: article.html,
articleId: article.id,
)
// Your own trigger
TextButton(
onPressed: () => showInsightreaderAiInsights(
context: context,
content: article.html,
articleId: article.id,
),
child: const Text('AI Insights'),
)
The sheet fetches the summary and the 5W1H breakdown concurrently and shows Summary, Key Notes and 5W1H tabs.
13. Models and output structures #
SummaryResult #
| Field | Type | Meaning |
|---|---|---|
title |
String? |
Model headline, or the parsed document title. |
summary |
String |
2–4 sentences. |
keyPoints |
List<String> |
3–5 takeaways. |
readingTime |
int |
Whole minutes at 238 wpm, minimum 1. |
topics |
List<String> |
Detected topics. |
FiveWOneH #
| Field | Type | Meaning |
|---|---|---|
who |
List<String> |
People and organisations. |
what |
String |
The central event. |
when |
List<String> |
Temporal references. |
location |
List<String> |
Places. |
why |
String |
Cause or motivation. |
how |
String |
Method or process. |
hasContent |
bool |
Whether any field carries content. |
BatchSummaryResult #
| Field | Type | Meaning |
|---|---|---|
index |
int |
Position in the original input. |
summary |
SummaryResult? |
Present on success. |
errorDescription |
String? |
Present on failure. |
succeeded |
bool |
Whether a summary was produced. |
CategoryInsight #
| Field | Type | Meaning |
|---|---|---|
id / name |
String |
Stable id and display name. |
totalReads |
int |
Reads in the analytics window. |
totalReadDuration |
int |
Cumulative seconds. |
segmentCounts |
Map<TimeSegment, int> |
Per-segment counts. |
score |
double |
0–1: min(reads/50, 1) × 0.6 + min(avgSeconds/600, 1) × 0.4. |
ReadingStreak #
| Field | Type | Meaning |
|---|---|---|
currentStreak |
int |
Consecutive days with at least one read. |
longestStreak |
int |
All-time best. |
todayCount |
int |
Articles read today. |
goalTarget |
int |
Daily goal; 0 means none. |
goalMet |
bool |
Goal set and reached. |
FeedArticle #
id, slug, title, description, category, categoryId, imageUrl,
publishedAt, articleUrl.
14. Error handling #
Every failable API throws InsightreaderException, carrying a stable
InsightreaderErrorCode.
try {
final summary = await InsightreaderSdk.instance.summarizeHtml(html);
} on InsightreaderException catch (e) {
switch (e.code) {
case InsightreaderErrorCode.aiUnavailable:
showBanner('AI summaries are unavailable right now.');
case InsightreaderErrorCode.contentDeclinedBySafetyGuardrails:
showBanner("AI insights aren't available for this article.");
case InsightreaderErrorCode.htmlParsingFailed:
showBanner("There wasn't enough text to analyse.");
case InsightreaderErrorCode.batchLimitExceeded:
showBanner('Too many stories in one batch.');
default:
showBanner(e.message);
}
}
| Code | When |
|---|---|
notInitialized |
An API was called before initialize(). |
storageFailure |
The local database could not be opened or written. |
invalidInput |
A required argument was missing or malformed. |
htmlParsingFailed |
The HTML yielded no usable text. |
aiUnavailable |
AI disabled, no API key, or the provider refused the key. |
summarizationFailed |
The provider errored or returned an unusable response. |
contentDeclinedBySafetyGuardrails |
The model declined the content. |
unsupportedLanguage |
Reserved for providers with limited locale support. Gemini never raises it. |
batchLimitExceeded |
More than batchLimit items in one batch. |
notificationPermissionDenied |
The reader declined, or already had. |
Feed failures use a separate FeedError with a FeedErrorKind, delivered via
onFeedError rather than thrown.
15. AI and Gemini configuration #
Supplying the API key #
Never hard-code the key. Pass it from a build-time define:
const InsightreaderConfiguration(
gemini: GeminiConfiguration(
apiKey: String.fromEnvironment('GEMINI_API_KEY'),
),
)
flutter run --dart-define=GEMINI_API_KEY=your-key-here
flutter build apk --dart-define=GEMINI_API_KEY=your-key-here
A key shipped inside a mobile binary is extractable by anyone with the binary, whichever mechanism delivers it. Restrict the key in Google Cloud Console to the Generative Language API and to your app's bundle identifiers. Where the threat model warrants it, route through your own backend instead:
const GeminiConfiguration(
baseUrl: 'https://your-backend.example.com/gemini',
)
The key travels in the x-goog-api-key header, never in a query string, so it
cannot leak into proxy or server access logs.
Gemini options #
| Field | Default |
|---|---|
apiKey |
'' |
model |
gemini-3.1-flash-lite |
baseUrl |
https://generativelanguage.googleapis.com/v1beta |
requestTimeout |
45s |
temperature |
0.2 |
maxOutputTokens |
2048 |
maxInputCharacters |
6000 |
batchLimit |
10 |
maxConcurrentRequests |
4 |
model is configurable because Google retires models on its own schedule.
Firestore response cache #
Gemini output is cached by article id, so the model is called at most once per article across your whole user base.
Add firebase_core and initialize it before the SDK:
await Firebase.initializeApp();
await InsightreaderSdk.instance.initialize(configuration: config);
Firestore rules. Clients read only, one document at a time — get rather than
read, because read is get + list and denying list stops the cache being
enumerated or bulk-exported:
match /gni_ai_cache/{doc} { allow get: if true;
allow list, write: if false; }
match /gni_remote_config/{doc} { allow get: if true;
allow list, write: if false; }
Enable App Check enforcement for Cloud Firestore in the Firebase console too —
without it, allow get: if true is world-readable per document.
Writes go through your own endpoint, not the client. Document ids are derived
from article ids, which are public, so a client-writable cache can be seeded with
a poisoned entry that every later reader receives as a cache hit — and Firestore
rules can validate a document's shape but never its contents. Deploy a function
that derives the document id server-side and point writeEndpoint at it; leave
it null and this install simply never writes.
| Field | Default | Purpose |
|---|---|---|
enabled |
true |
Whether responses are read from the shared cache. |
remoteKillSwitchEnabled |
true |
Whether the remote kill switch is consulted. Independent of enabled. |
writeEndpoint |
null |
Your trusted writer. null means this install never writes. |
maxEntryAge |
30d |
Entries older than this are treated as a miss. |
databaseId |
'' |
Blank uses the default database. |
collection |
gni_ai_cache |
Cache collection. |
remoteConfigCollection |
gni_remote_config |
Remote kill switch. |
readTimeout |
5s |
How long a cache read may stall before falling through to Gemini. |
maxDocuments |
800 |
Deprecated. Eviction is server-side; it needs list + delete, which clients no longer have. |
To run without Firebase:
const InsightreaderConfiguration(aiCache: AiCacheConfiguration.disabled)
The cache fails soft: a missing Firebase app, denied rules or an unreachable network degrades to "no cache" and the AI call still runs.
A gni_remote_config/ai_enabled document with enabled: false disables AI
across all installs without an app update. Set it with an admin credential — the
rules above deny client writes, which is what stops anyone else creating it.
Note remoteKillSwitchEnabled is separate from enabled: a host that runs
without the shared cache is still reachable by the kill switch.
AiCacheConfiguration.disabled opts out of both.
Swapping the AI provider #
The AI layer sits behind the Summarizer interface, so the rest of the SDK is
independent of Gemini:
class MyBackendSummarizer implements Summarizer {
@override String get engineLabel => 'my-backend';
@override bool get isAvailable => true;
@override
Future<SummaryResult> summarize({required String text, String? title}) async {
// ...
}
@override
Future<FiveWOneH> extractFiveWOneH({
required String text,
String? title,
}) async {
// ...
}
}
16. Analytics forwarding #
The SDK links no analytics backend of its own — bundling one would put a second copy of Firebase inside apps that already use it. Events are handed to you:
InsightreaderSdk.instance.analyticsEventHandler = (name, parameters) {
FirebaseAnalytics.instance.logEvent(
name: name,
parameters: <String, Object>{
for (final entry in (parameters ?? const {}).entries)
if (entry.value != null) entry.key: entry.value!,
},
);
};
Safe to set before or after initialize(). Set analyticsEnabled: false to
suppress everything.
Prefer the explicit rebuild above over
parameters?.cast<String, Object>().castreturns a lazy view that throws on access if any value is ever null, so a single nullable param would surface as a crash inside your analytics callback rather than a dropped field. The SDK emits no nulls today — there is a test asserting it — but the cast makes your app depend on that staying true.
Event naming #
Every event name carries the _gni suffix of the shared cross-platform event
sheet, so one dashboard covers iOS, Android
and Flutter. Events that appear on the sheet match it exactly, and
test/analytics_events_test.dart fails the build if that drifts.
Booleans are emitted as 1/0 and string values are truncated to 100
characters, both because Firebase rejects the alternatives.
Coverage against the event sheet #
The Flutter SDK covers 18 of the sheet's 59 events. The gap is features, not instrumentation — these tabs describe capabilities this SDK does not have:
| Sheet tab | Events | Status |
|---|---|---|
| Personalised Feed | 4 of 5 | feed_hidden_cold_start_gni needs a cold-start threshold the SDK has no concept of. |
| Notif – Briefings | 5 of 6 | briefing_shown_gni is not implementable — see below. |
| AI Content | 5 of 6 | ai_summary_shown_gni is a host-side render event; the SDK cannot see your card appear. |
| Insights | 3 of 3 | Complete. |
| In-App Review | 1 of 5 | in_app_review reports neither presentation nor completion, and the SDK keeps no throttle state. |
| Widget | 0 of 8 | No home-screen widget in the Flutter SDK. |
| Notif – Streak Reminder | 0 of 6 | No such notification; the SDK has four time-segment briefings instead. |
| Notif – Streak Lost | 0 of 6 | As above. |
| In-App Update | 0 of 8 | Play In-App Update is Android-native and outside this SDK's scope. |
| Sign-in (Google) | 0 of 6 | The SDK does no authentication. |
Three deliberate divergences worth knowing about:
briefing_scheduled_gnifires once per slot, carryingslot. It used to be one aggregate event per refresh that could not say which briefings were armed.slotcarries four values —morning,afternoon,evening,night— because that is what this SDK schedules. Android's sheet only ever emitsmorningandevening, so a shared dashboard must expect the extra two.briefing_shown_gniis omitted.flutter_local_notificationsprovides no delivery callback for a scheduled notification; the OS posts it with no hook back into Dart. Android fires this from its ownAlarmManagerreceiver, which has no Flutter equivalent short of a background isolate.
ai_summary_failed_gni sets error_reason to the sheet's enum — currently always
gemini — and puts the exception text in error_detail. The sheet's firestore
value is unreachable here by design: cache writes are fire-and-forget and never
fail the surrounding summary.
17. Complete example #
import 'package:flutter/material.dart';
import 'package:insightreader_sdk/insightreader_sdk.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await InsightreaderSdk.instance.initialize(
configuration: InsightreaderConfiguration(
gemini: const GeminiConfiguration(
apiKey: String.fromEnvironment('GEMINI_API_KEY'),
),
aiCache: AiCacheConfiguration.disabled,
onStreakMilestone: (days) => debugPrint('🔥 $days-day streak'),
),
);
await InsightreaderSdk.instance.scheduleNotifications();
runApp(const NewsApp());
}
class NewsApp extends StatelessWidget {
const NewsApp({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
theme: ThemeData(colorSchemeSeed: const Color(0xFF3E66DF)),
darkTheme: ThemeData.dark(useMaterial3: true),
home: const HomeScreen(),
);
}
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
static const _mapping = ArticleMappingConfig(
mainKey: 'articles',
articleIdKey: 'id',
titleKey: 'title',
descriptionKey: 'description',
imageUrlKey: 'urlToImage',
dateKey: 'publishedAt',
categoryKey: 'category',
);
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: const Text('Today'),
actions: [
IconButton(
icon: const Icon(Icons.settings),
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const InsightreaderSettingsScreen(),
),
),
),
],
),
body: ListView(
children: [
InsightreaderArticleFeed(
source: const FeedDataSource.url('https://api.example.com/feed'),
configuration: _mapping,
maxArticles: 8,
onArticleTap: (tap) => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ArticleScreen(article: tap.article),
),
),
),
],
),
);
}
class ArticleScreen extends StatefulWidget {
const ArticleScreen({super.key, required this.article});
final FeedArticle article;
@override
State<ArticleScreen> createState() => _ArticleScreenState();
}
class _ArticleScreenState extends State<ArticleScreen> {
final _stopwatch = Stopwatch()..start();
@override
void dispose() {
_stopwatch.stop();
// Tracked on the way out, so the duration is real — it carries 40% of the
// category's engagement score.
InsightreaderSdk.instance.trackArticleRead(
articleId: widget.article.id,
category: widget.article.category,
categoryId: widget.article.categoryId,
readDuration: _stopwatch.elapsed.inSeconds,
);
super.dispose();
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: Text(widget.article.title)),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Text(widget.article.description),
),
floatingActionButton: InsightreaderAiInsightsButton(
content: widget.article.description,
articleId: widget.article.id,
),
);
}
18. Behaviour notes #
Data windows. Category queries consider the last analyticsWindowDays (90)
days. Raw events stay on disk until maxStoredEvents (10,000) prunes them
oldest-first, so widening the window later brings older reads back into scope.
Pruning trims raw events only — the aggregate counts personalisation is built on
are never reduced.
Streaks. A streak advances at most once per calendar day, extends on a
consecutive day, and resets to 1 after any gap. longestStreak is never reduced.
Milestones fire at 3, 7, 14, 30, 60, 100 and 365 days.
Reader preferences win. Notification toggles are persisted and re-applied on
top of your configuration at every initialize(). A reader who turned
notifications off stays off.
The SDK never navigates. Every tap arrives on your callback or on
onNotificationTap.
Notification ownership. Only the SDK's own notification ids are ever cancelled — your app's local notifications are never collateral damage.
Android delivery drift. Briefings use inexact alarms, because Google Play
restricts SCHEDULE_EXACT_ALARM to alarm-clock and calendar apps and rejects
other categories that declare it. Delivery can drift by a few minutes. iOS fires
exactly.
Privacy. The reading profile and event log live only on this device. AI
sends article text to Gemini and, when caching is on, stores the result in your
Firestore project. clearAllData() erases everything local.
Differences from the iOS SDK #
Three deliberate divergences, all documented at their call sites:
maxStoredEventsandanalyticsWindowDaysare honoured. iOS accepts both but applies neither. PassanalyticsWindowDays: 0for byte-identical ranking against current iOS builds.- Batch concurrency is bounded. iOS fans a whole batch out at once against a
free on-device model; Gemini is metered, so this runs at most
maxConcurrentRequestsin parallel. Results and ordering are unaffected. - AI responses are cached. iOS has no cache because on-device inference is free.
Licence #
Proprietary — Mediology Software. Contact Mediology for licensing.