Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
003d2bf658 | ||
|
|
3bd3b25dd9 | ||
|
|
a7aaa8efe2 |
@@ -1 +1 @@
|
|||||||
const int dbSchemaVersion = 39;
|
const int dbSchemaVersion = 40;
|
||||||
|
|||||||
@@ -338,6 +338,17 @@ class EmailNotes extends Table {
|
|||||||
Set<Column> get primaryKey => {id};
|
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).
|
/// App-wide user preferences, stored as a singleton row (id always 1).
|
||||||
@DataClassName('UserPreferencesRow')
|
@DataClassName('UserPreferencesRow')
|
||||||
class UserPreferences extends Table {
|
class UserPreferences extends Table {
|
||||||
@@ -384,6 +395,7 @@ class UserPreferences extends Table {
|
|||||||
UserPreferences,
|
UserPreferences,
|
||||||
ImageTrustedSenders,
|
ImageTrustedSenders,
|
||||||
EmailNotes,
|
EmailNotes,
|
||||||
|
InstalledVersions,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
class AppDatabase extends _$AppDatabase {
|
class AppDatabase extends _$AppDatabase {
|
||||||
@@ -663,8 +675,30 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
if (from < 39) {
|
if (from < 39) {
|
||||||
await m.createTable(emailNotes);
|
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().
|
// Resolved once in main() via initDatabasePath() before runApp().
|
||||||
|
|||||||
@@ -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).
|
/// Stream of notes for a specific email, identified by (accountId, messageId).
|
||||||
final notesProvider =
|
final notesProvider =
|
||||||
StreamProvider.autoDispose.family<List<EmailNote>, (String, String)>(
|
StreamProvider.autoDispose.family<List<EmailNote>, (String, String)>(
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ class SharedInboxApp extends ConsumerStatefulWidget {
|
|||||||
ConsumerState<SharedInboxApp> createState() => _SharedInboxAppState();
|
ConsumerState<SharedInboxApp> createState() => _SharedInboxAppState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const _kGitHash = String.fromEnvironment('GIT_HASH');
|
||||||
|
|
||||||
class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -93,6 +95,11 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
|||||||
// Start background IMAP sync once — runs for the lifetime of the app.
|
// Start background IMAP sync once — runs for the lifetime of the app.
|
||||||
ref.read(syncManagerProvider).start();
|
ref.read(syncManagerProvider).start();
|
||||||
ref.read(reliabilityRunnerProvider).start();
|
ref.read(reliabilityRunnerProvider).start();
|
||||||
|
if (_kGitHash.isNotEmpty) {
|
||||||
|
unawaited(
|
||||||
|
ref.read(dbProvider).recordInstalledVersionIfNew(_kGitHash),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -2,21 +2,79 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.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';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
class ChangeLogScreen extends StatelessWidget {
|
class ChangeLogScreen extends ConsumerWidget {
|
||||||
const ChangeLogScreen({super.key});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final installedVersions = ref.watch(installedVersionsProvider);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('ChangeLog')),
|
appBar: AppBar(title: const Text('ChangeLog')),
|
||||||
body: FutureBuilder<String>(
|
body: FutureBuilder<String>(
|
||||||
future: DefaultAssetBundle.of(
|
future:
|
||||||
context,
|
DefaultAssetBundle.of(context).loadString('assets/changelog.txt'),
|
||||||
).loadString('assets/changelog.txt'),
|
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
if (snapshot.connectionState == ConnectionState.waiting ||
|
||||||
|
installedVersions.isLoading) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
if (snapshot.hasError) {
|
if (snapshot.hasError) {
|
||||||
@@ -25,8 +83,10 @@ class ChangeLogScreen extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
final content = snapshot.data ?? 'No changelog entries found.';
|
final content = snapshot.data ?? 'No changelog entries found.';
|
||||||
|
final versions = installedVersions.value ?? {};
|
||||||
|
final annotated = _injectInstallMarkers(content, versions);
|
||||||
return Markdown(
|
return Markdown(
|
||||||
data: content,
|
data: annotated,
|
||||||
onTapLink: (text, href, title) {
|
onTapLink: (text, href, title) {
|
||||||
if (href != null) {
|
if (href != null) {
|
||||||
unawaited(
|
unawaited(
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ void main() {
|
|||||||
group('Migration', () {
|
group('Migration', () {
|
||||||
test('schemaVersion matches expected value', () async {
|
test('schemaVersion matches expected value', () async {
|
||||||
final db = AppDatabase(NativeDatabase.memory());
|
final db = AppDatabase(NativeDatabase.memory());
|
||||||
expect(db.schemaVersion, 39);
|
expect(db.schemaVersion, 40);
|
||||||
await db.close();
|
await db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -427,12 +427,15 @@ void main() {
|
|||||||
// v39: email_notes table.
|
// v39: email_notes table.
|
||||||
await db.customSelect('SELECT count(*) FROM email_notes').get();
|
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();
|
await db.close();
|
||||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
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());
|
final db = AppDatabase(NativeDatabase.memory());
|
||||||
await db.select(db.accounts).get();
|
await db.select(db.accounts).get();
|
||||||
|
|
||||||
@@ -462,6 +465,7 @@ void main() {
|
|||||||
'user_preferences', // v34
|
'user_preferences', // v34
|
||||||
'image_trusted_senders', // v37
|
'image_trusted_senders', // v37
|
||||||
'email_notes', // v39
|
'email_notes', // v39
|
||||||
|
'installed_versions', // v40
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -500,6 +504,9 @@ void main() {
|
|||||||
// v39: email_notes table.
|
// v39: email_notes table.
|
||||||
await db.customSelect('SELECT count(*) FROM email_notes').get();
|
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();
|
await db.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:drift/native.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_test/flutter_test.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';
|
import 'package:sharedinbox/ui/screens/changelog_screen.dart';
|
||||||
|
|
||||||
class _FakeAssetBundle extends CachingAssetBundle {
|
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 =
|
const _fakeChangelog =
|
||||||
'* 2024-01-01 feat: initial release\n* 2024-01-02 fix: resolve crash\n';
|
'* 2024-01-01 feat: initial release\n* 2024-01-02 fix: resolve crash\n';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('ChangeLogScreen shows changelog content', (tester) async {
|
testWidgets('ChangeLogScreen shows changelog content', (tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
DefaultAssetBundle(
|
_buildScreen(assets: {'assets/changelog.txt': _fakeChangelog}),
|
||||||
bundle: _FakeAssetBundle({'assets/changelog.txt': _fakeChangelog}),
|
|
||||||
child: const MaterialApp(home: ChangeLogScreen()),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
@@ -41,14 +62,44 @@ void main() {
|
|||||||
testWidgets('ChangeLogScreen shows error when asset is missing', (
|
testWidgets('ChangeLogScreen shows error when asset is missing', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(_buildScreen(assets: {}));
|
||||||
DefaultAssetBundle(
|
|
||||||
bundle: _FakeAssetBundle({}),
|
|
||||||
child: const MaterialApp(home: ChangeLogScreen()),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.textContaining('Error loading changelog'), findsOneWidget);
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user