Compare commits

..
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 a167ebd0a3 feat: add build mode, Dart version, timestamp to crash report (#205)
Show app version, build mode, and platform OS in the crash screen UI
so users can report bugs without needing to copy first.  The clipboard
report now also includes Build Mode (debug/release/profile), Dart
runtime version, and a UTC timestamp to aid post-crash diagnosis.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 16:06:32 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 881a240155 fix: probe scanner method channel to detect MissingPluginException (#204)
The previous pre-flight check called ctrl.start() without ctrl.attach(),
causing MobileScannerController to time out after 500 ms with
controllerNotAttached on every device — the scanner never worked even
when the plugin was present.

Replace with a direct invokeMethod('state') probe on the scanner channel.
MissingPluginException (thrown when the plugin is not registered) is the
exact error from the crash report; all other errors are surfaced by the
MobileScanner widget's own error builder.

Also add widget tests for the AccountReceiveScreen step-2 import flow
(text fallback, successful import, invalid-QR error) which were
previously untested.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 15:47:21 +02:00
Thomas SharedInbox 117b546b2c feat: show live countdown with seconds on receive account screen (#203)
Replace the static "expires in 20 minutes" label with a StatefulWidget
that ticks every second and displays the remaining time as MM:SS.
2026-05-24 15:11:56 +02:00
17 changed files with 32 additions and 509 deletions
+2 -2
View File
@@ -38,7 +38,7 @@ jobs:
echo "Changed files:"
echo "$CHANGED"
android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/|scripts/deploy_playstore\.py)'
android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/)'
linux_re='^(linux/|lib/|pubspec\.yaml|pubspec\.lock)'
echo "$CHANGED" | grep -qE "$android_re" \
@@ -97,7 +97,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 100
fetch-depth: 1
- name: Check runner tools
run: |
+1 -2
View File
@@ -28,8 +28,7 @@ android/.gradle/
android/local.properties
android/app/google-services.json
android/key.properties
# android/app/src/main/java/io/flutter/plugins/ intentionally tracked so that
# GeneratedPluginRegistrant.java (catch Throwable) is committed and used by CI.
android/app/src/main/java/io/flutter/plugins/
.android/
Android/
.gradle/
+6 -20
View File
@@ -10,21 +10,9 @@ CLI tool `fgj` is available to query issues/PRs/actions.
We use issues, follow this label state machine:
- **State/ToPlan** — Issue needs a plan written by an agent before implementation
- **State/Planned** — Plan has been posted as a comment; awaiting human review
- **State/Ready** — Issue is approved and ready for implementation
- **State/InProgress** — Set while an agent (or human) is actively working
- **State/Question** — Agent hit a blocker or needs clarification
Full lifecycle:
```
State/ToPlan → State/Planned (automated: agent_loop.py runs a planning agent)
State/Planned → State/Ready (manual: human reviews the plan and approves)
State/Ready → State/InProgress (automated: agent_loop.py before starting implementation)
State/InProgress → closed (automated: after PR is merged and CI passes)
any state → State/Question (automated or manual: when blocked)
```
- **State/Ready** — Issue is available to pick up
- **State/InProgress** — Set this when you start working on an issue
- **State/Question** — Set this when you hit a blocker or need clarification
List open issues ready to pick up:
@@ -34,11 +22,9 @@ fgj issue list --json --state open | jq '[.[] | select(.labels[].name == "State/
Rules:
- Never start implementation on an issue without `State/Ready`
- Planning agents only post a plan comment — they do NOT write code or open PRs
- After `State/Planned`, a human must review the plan and manually add `State/Ready`
- When working via the agent loop: label transitions are set automatically
by `agent_loop.py` — do **not** set them yourself.
- Never start work on an issue without `State/Ready`
- When working via the agent loop: `State/Ready``State/InProgress` is set automatically
by `agent_loop.py` before the agent starts — do **not** set it yourself.
- When working manually: switch to `State/InProgress` as your **first action**:
```bash
fgj issue edit <NUMBER> --remove-label "State/Ready" --add-label "State/InProgress"
-1
View File
@@ -238,7 +238,6 @@ tasks:
publish-android:
desc: Build cached AAB, stamp versionCode, sign, and publish to Play Store via Dagger
deps: [generate-changelog]
preconditions:
- sh: test -n "$PLAY_STORE_CONFIG_JSON"
msg: "PLAY_STORE_CONFIG_JSON is not set"
+1
View File
@@ -4,6 +4,7 @@ gradle-wrapper.jar
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
@@ -1,84 +0,0 @@
package io.flutter.plugins;
import androidx.annotation.Keep;
import androidx.annotation.NonNull;
import io.flutter.Log;
import io.flutter.embedding.engine.FlutterEngine;
/**
* Generated file. Do not edit.
* This file is generated by the Flutter tool based on the
* plugins that support the Android platform.
*/
@Keep
public final class GeneratedPluginRegistrant {
private static final String TAG = "GeneratedPluginRegistrant";
public static void registerWith(@NonNull FlutterEngine flutterEngine) {
try {
flutterEngine.getPlugins().add(new com.mr.flutter.plugin.filepicker.FilePickerPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin file_picker, com.mr.flutter.plugin.filepicker.FilePickerPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_local_notifications, com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_plugin_android_lifecycle, io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_secure_storage, com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.flutter.plugins.integration_test.IntegrationTestPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin integration_test, dev.flutter.plugins.integration_test.IntegrationTestPlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.steenbakker.mobile_scanner.MobileScannerPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin mobile_scanner, dev.steenbakker.mobile_scanner.MobileScannerPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.crazecoder.openfile.OpenFilePlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin open_filex, com.crazecoder.openfile.OpenFilePlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin package_info_plus, dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin path_provider_android, io.flutter.plugins.pathprovider.PathProviderPlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.share.SharePlusPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin share_plus, dev.fluttercommunity.plus.share.SharePlusPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.urllauncher.UrlLauncherPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.webviewflutter.WebViewFlutterPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin webview_flutter_android, io.flutter.plugins.webviewflutter.WebViewFlutterPlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.fluttercommunity.workmanager.WorkmanagerPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin workmanager_android, dev.fluttercommunity.workmanager.WorkmanagerPlugin", e);
}
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ export SSH_PRIVATE_KEY=$(cat "$HOME/.ssh/id_ed25519")
# Add nix profile and nix store tools (task, dagger) to PATH
export PATH="$HOME/.nix-profile/bin:$PATH"
for pkg in "*go-task-*/bin/task" "*dagger-*/bin/dagger" "*fgj-*/bin/fgj"; do
for pkg in "*go-task-*/bin/task" "*dagger-*/bin/dagger"; do
bin=$(ls -d /nix/store/$pkg 2>/dev/null | sort -V | tail -1)
[ -n "$bin" ] && export PATH="$(dirname "$bin"):$PATH"
done
+4 -25
View File
@@ -95,30 +95,6 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
}
}
Future<void> _launchUrl(BuildContext context, Uri url) async {
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'),
),
);
}
}
}
Future<void> _createIssue(
BuildContext context,
int imapCount,
@@ -191,7 +167,10 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
onTapLink: (text, href, title) {
if (href != null) {
unawaited(
_launchUrl(context, Uri.parse(href)),
launchUrl(
Uri.parse(href),
mode: LaunchMode.externalApplication,
),
);
}
},
+2 -2
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -12,8 +13,7 @@ class ChangeLogScreen extends StatelessWidget {
return Scaffold(
appBar: AppBar(title: const Text('ChangeLog')),
body: FutureBuilder<String>(
future:
DefaultAssetBundle.of(context).loadString('assets/changelog.txt'),
future: rootBundle.loadString('assets/changelog.txt'),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
+1 -33
View File
@@ -37,14 +37,11 @@ class CrashScreen extends StatelessWidget {
final version = await _fetchVersion();
final platform =
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
final versionDisplay = gitHash.isNotEmpty
? '[$version](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)'
: version;
final gitLine = gitHash.isNotEmpty
? 'Git Commit: [$gitHash](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)\n'
: '';
final timestamp = DateTime.now().toUtc().toIso8601String();
return 'App Version: $versionDisplay\n'
return 'App Version: $version\n'
'Build Mode: $_buildMode\n'
'$gitLine'
'Platform: $platform\n'
@@ -89,35 +86,6 @@ class CrashScreen extends StatelessWidget {
),
if (gitHash.isNotEmpty) ...[
const SizedBox(height: 8),
FutureBuilder<PackageInfo>(
future: PackageInfo.fromPlatform(),
builder: (_, snapshot) {
if (!snapshot.hasData) return const SizedBox.shrink();
final version =
'${snapshot.data!.version}+${snapshot.data!.buildNumber}';
return GestureDetector(
onTap: () async {
final url = Uri.parse(
'https://codeberg.org/guettli/sharedinbox/commit/$gitHash',
);
await launchUrl(
url,
mode: LaunchMode.externalApplication,
);
},
child: Text(
'App Version: $version',
style: const TextStyle(
fontSize: 12,
color: Colors.blue,
decoration: TextDecoration.underline,
),
textAlign: TextAlign.center,
),
);
},
),
const SizedBox(height: 4),
GestureDetector(
onTap: () async {
final url = Uri.parse(
+3 -3
View File
@@ -1117,13 +1117,13 @@ packages:
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: "direct overridden"
dependency: transitive
description:
name: url_launcher_android
sha256: "5c8b6c2d89a78f5a1cca70a73d9d5f86c701b36b42f9c9dac7bad592113c28e9"
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
url: "https://pub.dev"
source: hosted
version: "6.3.24"
version: "6.3.30"
url_launcher_ios:
dependency: transitive
description:
-4
View File
@@ -89,7 +89,3 @@ dependency_overrides:
# (SIGSEGV in libdartjni.so FindClassUnchecked). Pin to 2.2.20 which uses
# stable Pigeon and is known to work reliably.
path_provider_android: ">=2.2.0 <2.2.21"
# url_launcher_android 6.3.25 updated to Pigeon 26, which causes a
# channel-error on launchUrl on some Android devices (same root cause as
# path_provider_android). Pin to <6.3.25 which uses stable Pigeon.
url_launcher_android: ">=6.3.0 <6.3.25"
+11 -98
View File
@@ -8,25 +8,21 @@ Flow
a. Age > 1 h → kill it, set its issue to State/Question, exit 1
b. Age ≤ 1 h → print status, exit 0 (let it keep working)
2. No agent running → extract pending_issue from state (if any), then check CI
a. pending_issue type=="plan" → post resume comment, set State/Planned, exit 0
b. pending_issue + open PR → check PR branch CI, merge/fix/wait as needed
c. Catch-up: orphaned issue-N-fix PRs with passing CI → merge them
d. Main CI running → save pending-ci state, exit 0
e. Main CI failed → start fix-CI agent (pushes fix to main), exit 0
f. Main CI ok + pending_issue → close the issue, exit 0 (dead code path —
section 2b always returns first)
g. Main CI ok (or no run yet) → find oldest ToPlan issue, start plan agent,
a. pending_issue + open PR → check PR branch CI, merge/fix/wait as needed
b. Catch-up: orphaned issue-N-fix PRs with passing CI merge them
c. Main CI running → save pending-ci state, exit 0
d. Main CI failed → start fix-CI agent (pushes fix to main), exit 0
e. Main CI ok + pending_issue → close the issue, exit 0 (dead code path —
section 2a always returns first)
f. Main CI ok (or no run yet) → find oldest Ready issue, start issue agent,
save state, exit 0
h. No ToPlan issues → find oldest Ready issue, start issue agent,
save state, exit 0
i. No Ready issues → print "nothing to do", exit 0
g. No Ready issues print "nothing to do", exit 0
Issue agents must NOT close the issue themselves; the loop closes it after CI passes.
Plan agents must NOT write any code or create PRs; they only post a plan comment.
State file: ~/.sharedinbox-agent-state.json
{ "pid": 12345, "issue": 91,
"started_at": "2026-05-15T12:00:00+00:00", "type": "issue|plan|ci-fix|pending-ci" }
"started_at": "2026-05-15T12:00:00+00:00", "type": "issue" }
Output is written to ~/.sharedinbox-agent-logs/<session>-<timestamp>.log.
To resume the Claude conversation, look up the session UUID first:
@@ -67,8 +63,6 @@ LABEL_READY = "State/Ready"
LABEL_IN_PROGRESS = "State/InProgress"
LABEL_QUESTION = "State/Question"
LABEL_PRIO_HIGH = "Prio/High"
LABEL_TO_PLAN = "State/ToPlan"
LABEL_PLANNED = "State/Planned"
# Only pick up issues filed by these accounts.
ALLOWED_ISSUE_AUTHORS = {"guettli", "guettlibot", "guettlibot2"}
@@ -151,26 +145,6 @@ def _ready_issues() -> list[dict]:
return ready
def _to_plan_issues() -> list[dict]:
"""Return open issues with State/ToPlan, Prio/High first, then oldest."""
result = subprocess.run(
["fgj", "--hostname", "codeberg.org", "issue", "list",
"--repo", REPO, "--state", "open", "--json"],
capture_output=True, text=True, check=True,
)
data = json.loads(result.stdout) if result.stdout.strip() else []
to_plan = [
i for i in data
if any(lbl["name"] == LABEL_TO_PLAN for lbl in i.get("labels", []))
and i.get("user", {}).get("login", "") in ALLOWED_ISSUE_AUTHORS
]
to_plan.sort(key=lambda i: (
0 if any(lbl["name"] == LABEL_PRIO_HIGH for lbl in i.get("labels", [])) else 1,
i["number"],
))
return to_plan
def _latest_main_ci_run() -> dict | None:
"""Return the latest ci.yml run on the main branch.
@@ -263,14 +237,6 @@ def _latest_ci_run_for_pr(pr_number: int) -> dict | None:
return None
def _get_issue_labels(issue: int) -> list[str]:
"""Return label names for an issue."""
data = _tea_get(f"repos/{REPO}/issues/{issue}")
if not data:
return []
return [lbl["name"] for lbl in data.get("labels", [])]
def _merge_pr(pr_number: int) -> None:
"""Squash-merge a PR via fgj."""
_fgj("pr", "merge", str(pr_number), "--repo", REPO, "--merge-method", "squash")
@@ -538,29 +504,13 @@ def _run_loop() -> int:
# Agent not running (or no state) — extract any pending issue, then clean up.
pending_issue: int | None = None
pending_type: str | None = None
ci_run_id_at_start: int | None = None
if state:
pending_issue = state.get("issue")
pending_type = state.get("type")
ci_run_id_at_start = state.get("ci_run_id_at_start")
_clear_state()
# ── 2a. Finished planning agent ───────────────────────────────────────────
if pending_issue and pending_type == "plan":
session_name = f"plan-issue-{pending_issue}"
uuid = _find_session_uuid(session_name)
if uuid:
resume_cmd = f"claude --resume {shlex.quote(uuid)}"
_comment_issue(
pending_issue,
f"Planning complete. To resume this session:\n\n```\n{resume_cmd}\n```",
)
_set_labels(pending_issue, add=[LABEL_PLANNED], remove=[LABEL_IN_PROGRESS])
print(f"Planning done for {_issue_url(pending_issue)} — set State/Planned.")
return 0
# ── 2b. Check for a PR opened by the agent ───────────────────────────────
# ── 2. Check for a PR opened by the agent ────────────────────────────────
if pending_issue:
branch = f"issue-{pending_issue}-fix"
pr = _find_pr_for_branch(branch)
@@ -692,9 +642,6 @@ def _run_loop() -> int:
continue
if pr_run and pr_run.get("status") == "success":
if issue_num and LABEL_QUESTION in _get_issue_labels(issue_num):
print(f"Catch-up: PR #{pr_number} — issue #{issue_num} is State/Question, skipping.")
continue
print(f"Catch-up: CI passed on PR #{pr_number} ({pr_url}) — merging.")
try:
_merge_pr(pr_number)
@@ -791,44 +738,10 @@ def _run_loop() -> int:
print(f"CI passed{ci_run_part} — closed {_issue_url(pending_issue)}.")
return 0
# Find a ToPlan issue — planning takes priority over implementation.
to_plan = _to_plan_issues()
if to_plan:
issue = to_plan[0]
issue_number = issue["number"]
issue_title = issue["title"]
issue_body = issue.get("body", "")
print(f"Starting planning agent for {_issue_url(issue_number)} {issue_title}")
_set_labels(issue_number, add=[LABEL_IN_PROGRESS], remove=[LABEL_TO_PLAN])
plan_prompt = f"""Analyze Codeberg issue #{issue_number} in the guettli/sharedinbox repository and write a detailed implementation plan.
Issue title: {issue_title}
Issue body:
{issue_body}
Instructions:
- Read and understand the issue thoroughly.
- Explore the relevant parts of the codebase to understand the current structure.
- Write a detailed implementation plan as a comment on the issue using:
fgj issue comment {issue_number} --repo {REPO} --body "..."
The plan should cover: which files to change, what approach to take, and any risks or open questions.
- Do NOT write any code, do NOT create any branches or PRs, do NOT modify any files.
- If the issue is unclear or you need more information, set the label to State/Question
and stop (do NOT close the issue).
- When you have posted the plan as an issue comment, stop.
"""
session_name = f"plan-issue-{issue_number}"
pid = _start_agent(plan_prompt, session_name)
_write_state(pid, issue_number, "plan", issue_title, session_name=session_name)
return 0
# Find a Ready issue.
issues = _ready_issues()
if not issues:
print("No issues with State/ToPlan or State/Ready. Nothing to do.")
print("No issues with State/Ready. Nothing to do.")
return 0
issue = issues[0]
-40
View File
@@ -744,45 +744,5 @@ class TestRunLoopResumeCommand(unittest.TestCase):
self.assertNotIn("Resume:", output)
class TestCatchupSkipsQuestionIssues(unittest.TestCase):
"""Catch-up must not retry merging a PR whose issue is already State/Question."""
def _make_pr(self, pr_number=50, branch="issue-10-fix"):
return {"number": pr_number, "head": {"ref": branch}}
def test_skips_merge_when_issue_has_question_label(self):
pr = self._make_pr()
ci_run = {"id": 999, "status": "success"}
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[pr]), \
patch("agent_loop._latest_ci_run_for_pr", return_value=ci_run), \
patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_QUESTION]), \
patch("agent_loop._merge_pr") as mock_merge, \
patch("agent_loop._comment_issue") as mock_comment, \
patch("agent_loop._set_labels") as mock_labels, \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_merge.assert_not_called()
mock_comment.assert_not_called()
mock_labels.assert_not_called()
def test_proceeds_with_merge_when_issue_lacks_question_label(self):
pr = self._make_pr()
ci_run = {"id": 999, "status": "success"}
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[pr]), \
patch("agent_loop._latest_ci_run_for_pr", return_value=ci_run), \
patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_IN_PROGRESS]), \
patch("agent_loop._merge_pr") as mock_merge, \
patch("agent_loop._find_pr_for_branch", return_value=None), \
patch("agent_loop._close_issue"):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_merge.assert_called_once_with(50)
if __name__ == "__main__":
unittest.main()
-36
View File
@@ -27,22 +27,6 @@ class MockUrlLauncher extends Mock
}
}
class ThrowingUrlLauncher extends Mock
with MockPlatformInterfaceMixin
implements UrlLauncherPlatform {
@override
Future<bool> canLaunch(String? url) async => true;
@override
Future<bool> launchUrl(String? url, LaunchOptions? options) async {
throw PlatformException(
code: 'channel-error',
message: 'Unable to establish connection on channel: '
'"dev.flutter.pigeon.url_launcher_android.UrlLauncherApi.launchUrl".',
);
}
}
Widget _buildScreen({List<Account> accounts = const []}) {
return ProviderScope(
overrides: [
@@ -196,24 +180,4 @@ void main() {
);
expect(mock.launchedUrl, contains('1.2.3%2B99'));
});
testWidgets(
'AboutScreen link tap with failed url_launcher shows error snackbar',
(tester) async {
tester.view.physicalSize = const Size(800, 1200);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
UrlLauncherPlatform.instance = ThrowingUrlLauncher();
await tester.pumpWidget(_buildScreen());
await tester.pumpAndSettle();
await tester.tap(find.textContaining('sharedinbox.de').first);
await tester.pumpAndSettle();
expect(find.textContaining('Error:'), findsOneWidget);
},
);
}
-54
View File
@@ -1,54 +0,0 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/ui/screens/changelog_screen.dart';
class _FakeAssetBundle extends CachingAssetBundle {
final Map<String, String> _assets;
_FakeAssetBundle(this._assets);
@override
Future<ByteData> load(String key) async {
if (_assets.containsKey(key)) {
final encoded = utf8.encode(_assets[key]!);
return ByteData.view(Uint8List.fromList(encoded).buffer);
}
throw FlutterError('Asset not found: "$key"');
}
}
const _fakeChangelog =
'* 2024-01-01 feat: initial release\n* 2024-01-02 fix: resolve crash\n';
void main() {
testWidgets('ChangeLogScreen shows changelog content', (tester) async {
await tester.pumpWidget(
DefaultAssetBundle(
bundle: _FakeAssetBundle({'assets/changelog.txt': _fakeChangelog}),
child: const MaterialApp(home: ChangeLogScreen()),
),
);
await tester.pumpAndSettle();
expect(find.text('ChangeLog'), findsOneWidget);
expect(find.textContaining('initial release'), findsOneWidget);
expect(find.textContaining('resolve crash'), findsOneWidget);
expect(find.textContaining('Error loading changelog'), findsNothing);
});
testWidgets('ChangeLogScreen shows error when asset is missing', (
tester,
) async {
await tester.pumpWidget(
DefaultAssetBundle(
bundle: _FakeAssetBundle({}),
child: const MaterialApp(home: ChangeLogScreen()),
),
);
await tester.pumpAndSettle();
expect(find.textContaining('Error loading changelog'), findsOneWidget);
});
}
-104
View File
@@ -147,7 +147,6 @@ void main() {
gitHash: testHash,
),
);
await tester.pumpAndSettle();
// Git hash link should be present
final gitLinkFinder = find.textContaining('Git Commit: abc1234');
@@ -200,109 +199,6 @@ void main() {
},
);
testWidgets(
'CrashScreen shows app version as clickable link when git hash is set',
(tester) async {
tester.view.physicalSize = const Size(800, 1200);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
final mock = MockUrlLauncher();
UrlLauncherPlatform.instance = mock;
const exception = 'TestException: version link test';
final stackTrace = StackTrace.current;
const testHash = 'abc1234';
await tester.pumpWidget(
CrashScreen(
exception: exception,
stackTrace: stackTrace,
gitHash: testHash,
),
);
await tester.pumpAndSettle();
// App version link should be present (mocked as 1.0.0+42)
final versionLinkFinder = find.textContaining('App Version: 1.0.0+42');
expect(versionLinkFinder, findsOneWidget);
// It must appear above the git hash link
final gitLinkFinder = find.textContaining('Git Commit: abc1234');
expect(
tester.getTopLeft(versionLinkFinder).dy,
lessThan(tester.getTopLeft(gitLinkFinder).dy),
);
// Tapping it should open the Codeberg commit URL
await tester.tap(versionLinkFinder);
await tester.pumpAndSettle();
expect(
mock.launchedUrl,
equals('https://codeberg.org/guettli/sharedinbox/commit/abc1234'),
);
},
);
testWidgets(
'CrashScreen copy-to-clipboard includes app version as markdown link when git hash is set',
(tester) async {
tester.view.physicalSize = const Size(800, 1200);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
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),
);
const exception = 'TestException: version link clipboard test';
final stackTrace = StackTrace.current;
const testHash = 'abc1234';
await tester.pumpWidget(
CrashScreen(
exception: exception,
stackTrace: stackTrace,
gitHash: testHash,
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Copy to Clipboard'));
await tester.pump();
await tester.pump();
await tester.pumpAndSettle();
expect(clipboardText, isNotNull);
// App Version must be a markdown link pointing to the commit
expect(
clipboardText,
contains(
'App Version: [1.0.0+42](https://codeberg.org/guettli/sharedinbox/commit/abc1234)',
),
);
expect(
clipboardText,
contains(
'Git Commit: [abc1234](https://codeberg.org/guettli/sharedinbox/commit/abc1234)',
),
);
},
);
testWidgets(
'CrashScreen used as root widget — buttons work without ScaffoldMessenger crash',
(tester) async {