diff --git a/Taskfile.yml b/Taskfile.yml index 0d95b35..f6954a2 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -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 diff --git a/lib/ui/screens/about_screen.dart b/lib/ui/screens/about_screen.dart index ff09091..d2d5852 100644 --- a/lib/ui/screens/about_screen.dart +++ b/lib/ui/screens/about_screen.dart @@ -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 createState() => _AboutScreenState(); + ConsumerState createState() => _AboutScreenState(); } -class _AboutScreenState extends State { +class _AboutScreenState extends ConsumerState { final Future _packageInfoFuture = PackageInfo.fromPlatform(); + late final Stream> _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 _copyToClipboard(BuildContext context) async { + static String _capitalize(String s) => + s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}'; + + Future _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 { } } - Future _createIssue(BuildContext context) async { + Future _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 { @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>( + 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( + 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( - future: _packageInfoFuture, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - return Markdown( - data: _buildMarkdown(context, snapshot.data), - selectable: true, - ); - }, - ), + ); + }, ); } } diff --git a/test/widget/about_screen_test.dart b/test/widget/about_screen_test.dart index bff68ff..b60e989 100644 --- a/test/widget/about_screen_test.dart +++ b/test/widget/about_screen_test.dart @@ -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 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));