feat: improve About screen with labeled versions, dark mode, account counts, and bottom buttons (#111)

- Rename "Version" to "App Version"; rename "OS Version" to platform-prefixed label (e.g. "Android Version")
- Link app version to its Codeberg git commit (via GIT_HASH dart-define)
- Add "Dark Mode" yes/no row
- Add IMAP Accounts and JMAP Accounts rows
- Move copy/create-issue actions from AppBar icons to labeled buttons below the table
- Pass GIT_HASH dart-define in Taskfile APK/AAB build commands

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas SharedInbox
2026-05-16 08:03:13 +02:00
co-authored by Claude Sonnet 4.6
parent 31d30b1074
commit aeb4c5ab41
3 changed files with 197 additions and 51 deletions
+2 -2
View File
@@ -348,7 +348,7 @@ tasks:
generates:
- build/app/outputs/flutter-apk/app-release.apk
cmds:
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build apk --release --no-pub | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build apk --release --no-pub --dart-define=GIT_HASH=$(git rev-parse --short HEAD) | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
deploy-android-bundle:
desc: Build release AAB and upload to Play Store internal track
@@ -370,7 +370,7 @@ tasks:
generates:
- build/app/outputs/bundle/release/app-release.aab
cmds:
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build appbundle --release --no-pub --build-number $(date +%s) --build-name $(date +%y%m%d-%H%M) | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build appbundle --release --no-pub --build-number $(date +%s) --build-name $(date +%y%m%d-%H%M) --dart-define=GIT_HASH=$(git rev-parse --short HEAD) | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
deploy-android:
desc: Build release APK and upload via scp to $ANDROID_APK_SCP_USER@$ANDROID_APK_SCP_HOST:$ANDROID_APK_SCP_PATH
+125 -38
View File
@@ -4,48 +4,82 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/di.dart';
import 'package:url_launcher/url_launcher.dart';
class AboutScreen extends StatefulWidget {
class AboutScreen extends ConsumerStatefulWidget {
const AboutScreen({super.key});
@override
State<AboutScreen> createState() => _AboutScreenState();
ConsumerState<AboutScreen> createState() => _AboutScreenState();
}
class _AboutScreenState extends State<AboutScreen> {
class _AboutScreenState extends ConsumerState<AboutScreen> {
final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform();
late final Stream<List<Account>> _accountsStream;
String _buildMarkdown(BuildContext context, PackageInfo? pkg) {
static const _gitHash = String.fromEnvironment('GIT_HASH');
@override
void initState() {
super.initState();
_accountsStream = ref.read(accountRepositoryProvider).observeAccounts();
}
String _buildMarkdown(
BuildContext context,
PackageInfo? pkg,
int imapCount,
int jmapCount,
) {
final size = MediaQuery.of(context).size;
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
final physW = (size.width * pixelRatio).toInt();
final physH = (size.height * pixelRatio).toInt();
final version =
pkg != null ? '${pkg.version}+${pkg.buildNumber}' : 'unknown';
final versionDisplay = _gitHash.isNotEmpty
? '[$version](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)'
: version;
final osName = _capitalize(Platform.operatingSystem);
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
return '## sharedinbox.de\n\n'
'| Property | Value |\n'
'|----------|-------|\n'
'| Version | $version |\n'
'| App Version | $versionDisplay |\n'
'| Platform | ${Platform.operatingSystem} |\n'
'| OS Version | ${Platform.operatingSystemVersion} |\n'
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
'| Resolution | ${physW}x$physH px'
' (logical: ${size.width.toInt()}x${size.height.toInt()} pt,'
' ratio: ${pixelRatio.toStringAsFixed(1)}x) |\n'
'| Dart Version | ${Platform.version.split(' ').first} |\n'
'| Processors | ${Platform.numberOfProcessors} |\n';
'| Processors | ${Platform.numberOfProcessors} |\n'
'| Dark Mode | ${isDark ? 'yes' : 'no'} |\n'
'| IMAP Accounts | $imapCount |\n'
'| JMAP Accounts | $jmapCount |\n';
}
Future<void> _copyToClipboard(BuildContext context) async {
static String _capitalize(String s) =>
s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}';
Future<void> _copyToClipboard(
BuildContext context,
int imapCount,
int jmapCount,
) async {
PackageInfo? pkg;
try {
pkg = await _packageInfoFuture;
} catch (_) {}
if (!context.mounted) return;
await Clipboard.setData(
ClipboardData(text: _buildMarkdown(context, pkg)),
ClipboardData(
text: _buildMarkdown(context, pkg, imapCount, jmapCount),
),
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -57,13 +91,19 @@ class _AboutScreenState extends State<AboutScreen> {
}
}
Future<void> _createIssue(BuildContext context) async {
Future<void> _createIssue(
BuildContext context,
int imapCount,
int jmapCount,
) async {
PackageInfo? pkg;
try {
pkg = await _packageInfoFuture;
} catch (_) {}
if (!context.mounted) return;
final body = Uri.encodeComponent(_buildMarkdown(context, pkg));
final body = Uri.encodeComponent(
_buildMarkdown(context, pkg, imapCount, jmapCount),
);
final url = Uri.parse(
'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body',
);
@@ -92,34 +132,81 @@ class _AboutScreenState extends State<AboutScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('About'),
actions: [
IconButton(
icon: const Icon(Icons.copy),
tooltip: 'Copy to clipboard',
onPressed: () => unawaited(_copyToClipboard(context)),
return StreamBuilder<List<Account>>(
stream: _accountsStream,
builder: (context, accountSnapshot) {
final accounts = accountSnapshot.data ?? [];
final imapCount =
accounts.where((a) => a.type == AccountType.imap).length;
final jmapCount =
accounts.where((a) => a.type == AccountType.jmap).length;
return Scaffold(
appBar: AppBar(title: const Text('About')),
body: Column(
children: [
Expanded(
child: FutureBuilder<PackageInfo>(
future: _packageInfoFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
return Markdown(
data: _buildMarkdown(
context,
snapshot.data,
imapCount,
jmapCount,
),
selectable: true,
onTapLink: (text, href, title) {
if (href != null) {
unawaited(
launchUrl(
Uri.parse(href),
mode: LaunchMode.externalApplication,
),
);
}
},
);
},
),
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Row(
children: [
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.copy),
label: const Text('Copy to clipboard'),
onPressed: () => unawaited(
_copyToClipboard(context, imapCount, jmapCount),
),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton.icon(
icon: const Icon(Icons.bug_report),
label: const Text('Create issue'),
onPressed: () => unawaited(
_createIssue(context, imapCount, jmapCount),
),
),
),
],
),
),
],
),
IconButton(
icon: const Icon(Icons.bug_report),
tooltip: 'Create issue on Codeberg',
onPressed: () => unawaited(_createIssue(context)),
),
],
),
body: FutureBuilder<PackageInfo>(
future: _packageInfoFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
return Markdown(
data: _buildMarkdown(context, snapshot.data),
selectable: true,
);
},
),
);
},
);
}
}
+70 -11
View File
@@ -1,12 +1,17 @@
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:mockito/mockito.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/about_screen.dart';
import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart';
import 'helpers.dart';
class MockUrlLauncher extends Mock
with MockPlatformInterfaceMixin
implements UrlLauncherPlatform {
@@ -22,6 +27,16 @@ class MockUrlLauncher extends Mock
}
}
Widget _buildScreen({List<Account> accounts = const []}) {
return ProviderScope(
overrides: [
accountRepositoryProvider
.overrideWithValue(FakeAccountRepository(accounts)),
],
child: const MaterialApp(home: AboutScreen()),
);
}
void main() {
setUpAll(() {
PackageInfo.setMockInitialValues(
@@ -39,17 +54,62 @@ void main() {
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
const MaterialApp(home: AboutScreen()),
);
await tester.pumpWidget(_buildScreen());
await tester.pumpAndSettle();
expect(find.text('About'), findsOneWidget);
expect(find.textContaining('Version'), findsWidgets);
expect(find.textContaining('App Version'), findsWidgets);
expect(find.textContaining('1.2.3+99'), findsOneWidget);
expect(find.textContaining('Resolution'), findsWidgets);
expect(find.textContaining('Dark Mode'), findsWidgets);
expect(find.textContaining('IMAP Accounts'), findsWidgets);
expect(find.textContaining('JMAP Accounts'), findsWidgets);
// Buttons are in the body, not in the AppBar actions
expect(find.byIcon(Icons.copy), findsOneWidget);
expect(find.byIcon(Icons.bug_report), findsOneWidget);
expect(find.text('Copy to clipboard'), findsOneWidget);
expect(find.text('Create issue'), findsOneWidget);
});
testWidgets('AboutScreen shows correct IMAP and JMAP account counts', (
tester,
) async {
tester.view.physicalSize = const Size(800, 1200);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
_buildScreen(
accounts: [
const Account(
id: 'imap-1',
displayName: 'Alice',
email: 'alice@example.com',
imapHost: 'imap.example.com',
smtpHost: 'smtp.example.com',
),
const Account(
id: 'imap-2',
displayName: 'Bob',
email: 'bob@example.com',
imapHost: 'imap.example.com',
smtpHost: 'smtp.example.com',
),
const Account(
id: 'jmap-1',
displayName: 'Carol',
email: 'carol@example.com',
type: AccountType.jmap,
jmapUrl: 'https://jmap.example.com',
),
],
),
);
await tester.pumpAndSettle();
expect(find.textContaining('IMAP Accounts'), findsWidgets);
expect(find.textContaining('JMAP Accounts'), findsWidgets);
});
testWidgets('AboutScreen copy button puts markdown in clipboard', (
@@ -76,9 +136,7 @@ void main() {
.setMockMethodCallHandler(SystemChannels.platform, null),
);
await tester.pumpWidget(
const MaterialApp(home: AboutScreen()),
);
await tester.pumpWidget(_buildScreen());
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.copy));
@@ -88,8 +146,11 @@ void main() {
expect(clipboardText, isNotNull);
expect(clipboardText, contains('1.2.3+99'));
expect(clipboardText, contains('Version'));
expect(clipboardText, contains('App Version'));
expect(clipboardText, contains('Resolution'));
expect(clipboardText, contains('Dark Mode'));
expect(clipboardText, contains('IMAP Accounts'));
expect(clipboardText, contains('JMAP Accounts'));
});
testWidgets('AboutScreen create-issue button opens Codeberg URL', (
@@ -103,9 +164,7 @@ void main() {
final mock = MockUrlLauncher();
UrlLauncherPlatform.instance = mock;
await tester.pumpWidget(
const MaterialApp(home: AboutScreen()),
);
await tester.pumpWidget(_buildScreen());
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.bug_report));