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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
88aa340000
commit
7fa19dd39a
@@ -2,6 +2,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
|
|
||||||
import 'package:sharedinbox/core/models/sieve_script.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_export_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/account_import_screen.dart';
|
import 'package:sharedinbox/ui/screens/account_import_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/account_list_screen.dart';
|
import 'package:sharedinbox/ui/screens/account_list_screen.dart';
|
||||||
@@ -47,6 +48,10 @@ final router = GoRouter(
|
|||||||
path: 'changelog',
|
path: 'changelog',
|
||||||
builder: (ctx, state) => const ChangeLogScreen(),
|
builder: (ctx, state) => const ChangeLogScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'about',
|
||||||
|
builder: (ctx, state) => const AboutScreen(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: ':accountId/edit',
|
path: ':accountId/edit',
|
||||||
builder: (ctx, state) => EditAccountScreen(
|
builder: (ctx, state) => EditAccountScreen(
|
||||||
|
|||||||
@@ -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<AboutScreen> createState() => _AboutScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AboutScreenState extends State<AboutScreen> {
|
||||||
|
final Future<PackageInfo> _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<void> _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<void> _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<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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,14 @@ class AccountListScreen extends ConsumerWidget {
|
|||||||
unawaited(context.push('/accounts/changelog'));
|
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'));
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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<bool> canLaunch(String? url) async => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> 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<dynamic, dynamic>)['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'));
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user