Compare commits

...
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 003d2bf658 fix: apply dart format to changelog_screen.dart and migration_test.dart
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 09:01:00 +02:00
guettli 3bd3b25dd9 Merge branch 'main' into issue-446-installed-versions-in-changelog 2026-06-06 05:43:33 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 a7aaa8efe2 feat: track installed versions and annotate ChangeLog with install dates
Adds an InstalledVersions table (schema v40) that records the first time
the user runs each app version (identified by GIT_HASH). On every app
start the current GIT_HASH is written to the table if not already present,
preserving the original install timestamp on subsequent launches.

The ChangeLog screen now reads the installed-versions map and injects
"Installed: HH:mm, d Mon YYYY" banners above each changelog entry whose
short git hash matches a recorded version. Closes #446.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 23:00:50 +02:00
7 changed files with 183 additions and 20 deletions
+1 -1
View File
@@ -1 +1 @@
const int dbSchemaVersion = 39;
const int dbSchemaVersion = 40;
+34
View File
@@ -338,6 +338,17 @@ class EmailNotes extends Table {
Set<Column> 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<Column> 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<void> recordInstalledVersionIfNew(String gitHash) async {
if (gitHash.isEmpty) return;
await into(installedVersions).insert(
InstalledVersionsCompanion.insert(
gitHash: gitHash,
installedAt: DateTime.now(),
),
mode: InsertMode.insertOrIgnore,
);
}
Future<Map<String, DateTime>> 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().
+4
View File
@@ -294,6 +294,10 @@ final noteRepositoryProvider = Provider<NoteRepository>((ref) {
);
});
final installedVersionsProvider = FutureProvider<Map<String, DateTime>>((ref) {
return ref.watch(dbProvider).loadInstalledVersions();
});
/// Stream of notes for a specific email, identified by (accountId, messageId).
final notesProvider =
StreamProvider.autoDispose.family<List<EmailNote>, (String, String)>(
+7
View File
@@ -86,6 +86,8 @@ class SharedInboxApp extends ConsumerStatefulWidget {
ConsumerState<SharedInboxApp> createState() => _SharedInboxAppState();
}
const _kGitHash = String.fromEnvironment('GIT_HASH');
class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
@override
void initState() {
@@ -93,6 +95,11 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
// 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
+67 -7
View File
@@ -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<String, DateTime> 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<String>(
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(
+9 -2
View File
@@ -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();
});
});
+61 -10
View File
@@ -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<String, String> assets,
Map<String, DateTime> 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);
});
}