From 7fa19dd39a4e87e92d900a4f3bbe4ce6b413b023 Mon Sep 17 00:00:00 2001 From: Thomas SharedInbox Date: Fri, 15 May 2026 23:50:55 +0200 Subject: [PATCH] feat(about): add About page with device/app info and issue reporting (#106) Shows version, platform, OS version, screen resolution, Dart version, and processor count in a markdown table. Buttons let users copy the info to clipboard or open a pre-filled Codeberg issue. Co-Authored-By: Claude Sonnet 4.6 --- lib/ui/router.dart | 5 + lib/ui/screens/about_screen.dart | 125 ++++++++++++++++++++++++ lib/ui/screens/account_list_screen.dart | 8 ++ test/widget/about_screen_test.dart | 120 +++++++++++++++++++++++ 4 files changed, 258 insertions(+) create mode 100644 lib/ui/screens/about_screen.dart create mode 100644 test/widget/about_screen_test.dart diff --git a/lib/ui/router.dart b/lib/ui/router.dart index a6a6900..10244c7 100644 --- a/lib/ui/router.dart +++ b/lib/ui/router.dart @@ -2,6 +2,7 @@ import 'package:go_router/go_router.dart'; import 'package:sharedinbox/core/models/sieve_script.dart'; +import 'package:sharedinbox/ui/screens/about_screen.dart'; import 'package:sharedinbox/ui/screens/account_export_screen.dart'; import 'package:sharedinbox/ui/screens/account_import_screen.dart'; import 'package:sharedinbox/ui/screens/account_list_screen.dart'; @@ -47,6 +48,10 @@ final router = GoRouter( path: 'changelog', builder: (ctx, state) => const ChangeLogScreen(), ), + GoRoute( + path: 'about', + builder: (ctx, state) => const AboutScreen(), + ), GoRoute( path: ':accountId/edit', builder: (ctx, state) => EditAccountScreen( diff --git a/lib/ui/screens/about_screen.dart b/lib/ui/screens/about_screen.dart new file mode 100644 index 0000000..3138577 --- /dev/null +++ b/lib/ui/screens/about_screen.dart @@ -0,0 +1,125 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class AboutScreen extends StatefulWidget { + const AboutScreen({super.key}); + + @override + State createState() => _AboutScreenState(); +} + +class _AboutScreenState extends State { + final Future _packageInfoFuture = PackageInfo.fromPlatform(); + + String _buildMarkdown(BuildContext context, PackageInfo? pkg) { + 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'; + + return '## SharedInbox\n\n' + '| Property | Value |\n' + '|----------|-------|\n' + '| Version | $version |\n' + '| Platform | ${Platform.operatingSystem} |\n' + '| OS 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'; + } + + Future _copyToClipboard(BuildContext context) async { + PackageInfo? pkg; + try { + pkg = await _packageInfoFuture; + } catch (_) {} + if (!context.mounted) return; + await Clipboard.setData( + ClipboardData(text: _buildMarkdown(context, pkg)), + ); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + duration: Duration(seconds: 5), + content: Text('Copied to clipboard'), + ), + ); + } + } + + Future _createIssue(BuildContext context) async { + PackageInfo? pkg; + try { + pkg = await _packageInfoFuture; + } catch (_) {} + if (!context.mounted) return; + final body = Uri.encodeComponent(_buildMarkdown(context, pkg)); + final url = Uri.parse( + 'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body', + ); + try { + final launched = + await launchUrl(url, mode: LaunchMode.externalApplication); + if (!launched && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + duration: Duration(seconds: 5), + content: Text('Could not open browser.'), + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + duration: const Duration(seconds: 5), + content: Text('Error: $e'), + ), + ); + } + } + } + + @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)), + ), + 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/lib/ui/screens/account_list_screen.dart b/lib/ui/screens/account_list_screen.dart index 9d86ea5..5bafa41 100644 --- a/lib/ui/screens/account_list_screen.dart +++ b/lib/ui/screens/account_list_screen.dart @@ -50,6 +50,14 @@ class AccountListScreen extends ConsumerWidget { unawaited(context.push('/accounts/changelog')); }, ), + ListTile( + leading: const Icon(Icons.info_outline), + title: const Text('About'), + onTap: () { + Navigator.pop(context); // Close drawer + unawaited(context.push('/accounts/about')); + }, + ), ], ), ), diff --git a/test/widget/about_screen_test.dart b/test/widget/about_screen_test.dart new file mode 100644 index 0000000..bff68ff --- /dev/null +++ b/test/widget/about_screen_test.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.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/ui/screens/about_screen.dart'; +import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; + +class MockUrlLauncher extends Mock + with MockPlatformInterfaceMixin + implements UrlLauncherPlatform { + String? launchedUrl; + + @override + Future canLaunch(String? url) async => true; + + @override + Future launchUrl(String? url, LaunchOptions? options) async { + launchedUrl = url; + return true; + } +} + +void main() { + setUpAll(() { + PackageInfo.setMockInitialValues( + appName: 'SharedInbox', + packageName: 'org.sharedinbox', + version: '1.2.3', + buildNumber: '99', + buildSignature: '', + ); + }); + + testWidgets('AboutScreen shows title and info table', (tester) async { + tester.view.physicalSize = const Size(800, 1200); + tester.view.devicePixelRatio = 2.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + const MaterialApp(home: AboutScreen()), + ); + await tester.pumpAndSettle(); + + expect(find.text('About'), findsOneWidget); + expect(find.textContaining('Version'), findsWidgets); + expect(find.textContaining('1.2.3+99'), findsOneWidget); + expect(find.textContaining('Resolution'), findsWidgets); + expect(find.byIcon(Icons.copy), findsOneWidget); + expect(find.byIcon(Icons.bug_report), findsOneWidget); + }); + + testWidgets('AboutScreen copy button puts markdown in clipboard', ( + tester, + ) async { + tester.view.physicalSize = const Size(800, 1200); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + String? clipboardText; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (MethodCall call) async { + if (call.method == 'Clipboard.setData') { + clipboardText = + (call.arguments as Map)['text'] as String?; + } + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null), + ); + + await tester.pumpWidget( + const MaterialApp(home: AboutScreen()), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.copy)); + await tester.pump(); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(clipboardText, isNotNull); + expect(clipboardText, contains('1.2.3+99')); + expect(clipboardText, contains('Version')); + expect(clipboardText, contains('Resolution')); + }); + + testWidgets('AboutScreen create-issue button opens Codeberg URL', ( + tester, + ) async { + tester.view.physicalSize = const Size(800, 1200); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final mock = MockUrlLauncher(); + UrlLauncherPlatform.instance = mock; + + await tester.pumpWidget( + const MaterialApp(home: AboutScreen()), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.bug_report)); + await tester.pumpAndSettle(); + + expect( + mock.launchedUrl, + contains('https://codeberg.org/guettli/sharedinbox/issues/new'), + ); + expect(mock.launchedUrl, contains('1.2.3%2B99')); + }); +}