insightreader_sdk 1.0.0 copy "insightreader_sdk: ^1.0.0" to clipboard
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 #

  1. Installation
  2. Initialization
  3. Configuration
  4. Permissions
  5. Tracking content
  6. Generating summaries
  7. Key takeaways
  8. 5W1H extraction
  9. Batch processing
  10. Notifications
  11. Personalisation APIs
  12. Article feeds
  13. Models and output structures
  14. Error handling
  15. AI and Gemini configuration
  16. Analytics forwarding
  17. Complete example
  18. 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 throws InsightreaderErrorCode.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>(). cast returns 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_gni fires once per slot, carrying slot. It used to be one aggregate event per refresh that could not say which briefings were armed.
  • slot carries four valuesmorning, afternoon, evening, night — because that is what this SDK schedules. Android's sheet only ever emits morning and evening, so a shared dashboard must expect the extra two.
  • briefing_shown_gni is omitted. flutter_local_notifications provides no delivery callback for a scheduled notification; the OS posts it with no hook back into Dart. Android fires this from its own AlarmManager receiver, 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:

  1. maxStoredEvents and analyticsWindowDays are honoured. iOS accepts both but applies neither. Pass analyticsWindowDays: 0 for byte-identical ranking against current iOS builds.
  2. 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 maxConcurrentRequests in parallel. Results and ordering are unaffected.
  3. AI responses are cached. iOS has no cache because on-device inference is free.

Licence #

Proprietary — Mediology Software. Contact Mediology for licensing.

0
likes
140
points
108
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Personalisation for Flutter news apps — reading tracking, time-of-day category insights, streaks, curated notifications, personalised feeds and Gemini AI summaries.

Homepage

Topics

#news #personalization #recommendations #notifications #ai

License

unknown (license)

Dependencies

cached_network_image, cloud_firestore, firebase_core, flutter, flutter_local_notifications, flutter_timezone, http, in_app_review, meta, path, permission_handler, shared_preferences, sqflite, timezone, url_launcher

More

Packages that depend on insightreader_sdk