From e28996cf867bb331ac205cb38f5c9713325a2167 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Sat, 6 Jun 2026 10:31:06 +0200 Subject: [PATCH] feat: track installed versions and annotate ChangeLog with install dates (#457) --- lib/core/db_schema_version.dart | 2 +- lib/data/db/database.dart | 34 ++++++++++++ lib/di.dart | 4 ++ lib/main.dart | 7 +++ lib/ui/screens/changelog_screen.dart | 74 +++++++++++++++++++++++--- test/unit/migration_test.dart | 11 +++- test/widget/changelog_screen_test.dart | 71 ++++++++++++++++++++---- 7 files changed, 183 insertions(+), 20 deletions(-) diff --git a/lib/core/db_schema_version.dart b/lib/core/db_schema_version.dart index d964cb9..dd07635 100644 --- a/lib/core/db_schema_version.dart +++ b/lib/core/db_schema_version.dart @@ -1 +1 @@ -const int dbSchemaVersion = 39; +const int dbSchemaVersion = 40; diff --git a/lib/data/db/database.dart b/lib/data/db/database.dart index d2d9c8e..103df36 100644 --- a/lib/data/db/database.dart +++ b/lib/data/db/database.dart @@ -338,6 +338,17 @@ class EmailNotes extends Table { Set get primaryKey => {id}; } +/// Records the first time the user ran each app version (identified by GIT_HASH). +/// Added in schema v40. +@DataClassName('InstalledVersionRow') +class InstalledVersions extends Table { + TextColumn get gitHash => text()(); + DateTimeColumn get installedAt => dateTime()(); + + @override + Set get primaryKey => {gitHash}; +} + /// App-wide user preferences, stored as a singleton row (id always 1). @DataClassName('UserPreferencesRow') class UserPreferences extends Table { @@ -384,6 +395,7 @@ class UserPreferences extends Table { UserPreferences, ImageTrustedSenders, EmailNotes, + InstalledVersions, ], ) class AppDatabase extends _$AppDatabase { @@ -663,8 +675,30 @@ class AppDatabase extends _$AppDatabase { if (from < 39) { await m.createTable(emailNotes); } + if (from < 40) { + await m.createTable(installedVersions); + } }, ); + + /// Inserts a row for [gitHash] the first time that version is seen. + /// Subsequent calls for the same hash are silently ignored so the original + /// install timestamp is preserved. + Future recordInstalledVersionIfNew(String gitHash) async { + if (gitHash.isEmpty) return; + await into(installedVersions).insert( + InstalledVersionsCompanion.insert( + gitHash: gitHash, + installedAt: DateTime.now(), + ), + mode: InsertMode.insertOrIgnore, + ); + } + + Future> loadInstalledVersions() async { + final rows = await select(installedVersions).get(); + return {for (final r in rows) r.gitHash: r.installedAt}; + } } // Resolved once in main() via initDatabasePath() before runApp(). diff --git a/lib/di.dart b/lib/di.dart index bfd7206..bf265d3 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -294,6 +294,10 @@ final noteRepositoryProvider = Provider((ref) { ); }); +final installedVersionsProvider = FutureProvider>((ref) { + return ref.watch(dbProvider).loadInstalledVersions(); +}); + /// Stream of notes for a specific email, identified by (accountId, messageId). final notesProvider = StreamProvider.autoDispose.family, (String, String)>( diff --git a/lib/main.dart b/lib/main.dart index d7ca483..20c6a2a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -86,6 +86,8 @@ class SharedInboxApp extends ConsumerStatefulWidget { ConsumerState createState() => _SharedInboxAppState(); } +const _kGitHash = String.fromEnvironment('GIT_HASH'); + class _SharedInboxAppState extends ConsumerState { @override void initState() { @@ -93,6 +95,11 @@ class _SharedInboxAppState extends ConsumerState { // Start background IMAP sync once — runs for the lifetime of the app. ref.read(syncManagerProvider).start(); ref.read(reliabilityRunnerProvider).start(); + if (_kGitHash.isNotEmpty) { + unawaited( + ref.read(dbProvider).recordInstalledVersionIfNew(_kGitHash), + ); + } } @override diff --git a/lib/ui/screens/changelog_screen.dart b/lib/ui/screens/changelog_screen.dart index 4008da2..2012242 100644 --- a/lib/ui/screens/changelog_screen.dart +++ b/lib/ui/screens/changelog_screen.dart @@ -2,21 +2,79 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:sharedinbox/di.dart'; import 'package:url_launcher/url_launcher.dart'; -class ChangeLogScreen extends StatelessWidget { +class ChangeLogScreen extends ConsumerWidget { const ChangeLogScreen({super.key}); + static const _months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + + static String _formatInstallDate(DateTime dt) { + final h = dt.hour.toString().padLeft(2, '0'); + final m = dt.minute.toString().padLeft(2, '0'); + final month = _months[dt.month - 1]; + return '$h:$m, ${dt.day} $month ${dt.year}'; + } + + // Changelog lines have the form: + // * 2026-06-05 [abc1234](https://...): subject + // This pattern captures the short hash inside the markdown link. + static final _hashPattern = RegExp(r'\[([0-9a-f]{6,12})\]\('); + + static String _injectInstallMarkers( + String changelog, + Map versions, + ) { + if (versions.isEmpty) return changelog; + final lines = changelog.split('\n'); + final buf = StringBuffer(); + for (final line in lines) { + final match = _hashPattern.firstMatch(line); + if (match != null) { + final lineHash = match.group(1)!; + for (final entry in versions.entries) { + final stored = entry.key; + final matches = stored == lineHash || + stored.startsWith(lineHash) || + lineHash.startsWith(stored); + if (!matches) continue; + buf.write( + '\n---\n\n**Installed: ${_formatInstallDate(entry.value)}**\n\n', + ); + break; + } + } + buf.writeln(line); + } + return buf.toString(); + } + @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final installedVersions = ref.watch(installedVersionsProvider); return Scaffold( appBar: AppBar(title: const Text('ChangeLog')), body: FutureBuilder( - future: DefaultAssetBundle.of( - context, - ).loadString('assets/changelog.txt'), + future: + DefaultAssetBundle.of(context).loadString('assets/changelog.txt'), builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { + if (snapshot.connectionState == ConnectionState.waiting || + installedVersions.isLoading) { return const Center(child: CircularProgressIndicator()); } if (snapshot.hasError) { @@ -25,8 +83,10 @@ class ChangeLogScreen extends StatelessWidget { ); } final content = snapshot.data ?? 'No changelog entries found.'; + final versions = installedVersions.value ?? {}; + final annotated = _injectInstallMarkers(content, versions); return Markdown( - data: content, + data: annotated, onTapLink: (text, href, title) { if (href != null) { unawaited( diff --git a/test/unit/migration_test.dart b/test/unit/migration_test.dart index 91939bb..e6e375f 100644 --- a/test/unit/migration_test.dart +++ b/test/unit/migration_test.dart @@ -14,7 +14,7 @@ void main() { group('Migration', () { test('schemaVersion matches expected value', () async { final db = AppDatabase(NativeDatabase.memory()); - expect(db.schemaVersion, 39); + expect(db.schemaVersion, 40); await db.close(); }); @@ -427,12 +427,15 @@ void main() { // v39: email_notes table. await db.customSelect('SELECT count(*) FROM email_notes').get(); + // v40: installed_versions table. + await db.customSelect('SELECT count(*) FROM installed_versions').get(); + await db.close(); if (dbFile.existsSync()) dbFile.deleteSync(); }, ); - test('fresh install creates all tables at schemaVersion 39', () async { + test('fresh install creates all tables at schemaVersion 40', () async { final db = AppDatabase(NativeDatabase.memory()); await db.select(db.accounts).get(); @@ -462,6 +465,7 @@ void main() { 'user_preferences', // v34 'image_trusted_senders', // v37 'email_notes', // v39 + 'installed_versions', // v40 ]), ); @@ -500,6 +504,9 @@ void main() { // v39: email_notes table. await db.customSelect('SELECT count(*) FROM email_notes').get(); + // v40: installed_versions table. + await db.customSelect('SELECT count(*) FROM installed_versions').get(); + await db.close(); }); }); diff --git a/test/widget/changelog_screen_test.dart b/test/widget/changelog_screen_test.dart index 8ddc1bd..6d9aae3 100644 --- a/test/widget/changelog_screen_test.dart +++ b/test/widget/changelog_screen_test.dart @@ -1,8 +1,12 @@ import 'dart:convert'; +import 'package:drift/native.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:sharedinbox/data/db/database.dart'; +import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/ui/screens/changelog_screen.dart'; class _FakeAssetBundle extends CachingAssetBundle { @@ -19,16 +23,33 @@ class _FakeAssetBundle extends CachingAssetBundle { } } +Widget _buildScreen({ + required Map assets, + Map installedVersions = const {}, +}) { + return ProviderScope( + overrides: [ + dbProvider.overrideWith((ref) { + final db = AppDatabase(NativeDatabase.memory()); + ref.onDispose(db.close); + return db; + }), + installedVersionsProvider.overrideWith((ref) async => installedVersions), + ], + child: DefaultAssetBundle( + bundle: _FakeAssetBundle(assets), + child: const MaterialApp(home: ChangeLogScreen()), + ), + ); +} + const _fakeChangelog = '* 2024-01-01 feat: initial release\n* 2024-01-02 fix: resolve crash\n'; void main() { testWidgets('ChangeLogScreen shows changelog content', (tester) async { await tester.pumpWidget( - DefaultAssetBundle( - bundle: _FakeAssetBundle({'assets/changelog.txt': _fakeChangelog}), - child: const MaterialApp(home: ChangeLogScreen()), - ), + _buildScreen(assets: {'assets/changelog.txt': _fakeChangelog}), ); await tester.pumpAndSettle(); @@ -41,14 +62,44 @@ void main() { testWidgets('ChangeLogScreen shows error when asset is missing', ( tester, ) async { - await tester.pumpWidget( - DefaultAssetBundle( - bundle: _FakeAssetBundle({}), - child: const MaterialApp(home: ChangeLogScreen()), - ), - ); + await tester.pumpWidget(_buildScreen(assets: {})); await tester.pumpAndSettle(); expect(find.textContaining('Error loading changelog'), findsOneWidget); }); + + testWidgets('ChangeLogScreen injects install marker for a known hash', ( + tester, + ) async { + const changelog = + '* 2024-01-01 [abc1234](https://example.com/abc1234): feat: initial release\n'; + final installedAt = DateTime(2024, 6, 15, 14, 32); + + await tester.pumpWidget( + _buildScreen( + assets: {'assets/changelog.txt': changelog}, + installedVersions: {'abc1234': installedAt}, + ), + ); + await tester.pumpAndSettle(); + + expect(find.textContaining('Installed: 14:32'), findsOneWidget); + expect(find.textContaining('15 Jun 2024'), findsOneWidget); + expect(find.textContaining('initial release'), findsOneWidget); + }); + + testWidgets('ChangeLogScreen shows no markers when no version recorded', ( + tester, + ) async { + const changelog = + '* 2024-01-01 [abc1234](https://example.com/abc1234): feat: initial release\n'; + + await tester.pumpWidget( + _buildScreen(assets: {'assets/changelog.txt': changelog}), + ); + await tester.pumpAndSettle(); + + expect(find.textContaining('Installed:'), findsNothing); + expect(find.textContaining('initial release'), findsOneWidget); + }); }