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:
co-authored by
Claude Sonnet 4.6
parent
31d30b1074
commit
aeb4c5ab41
+2
-2
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user