diff --git a/README.md b/README.md index 35530ce..f94a6d3 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend. | Dark Mode | Light Mode | | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------: | -| ![Screenshot of Nexus Client in dark mode, showing users talking, with a sidebar showing rooms and spaces, and another sidebar showing members](./assets/screenshotDark.avif) | ![The same screenshot as above, but in light mode](./assets/screenshotLight.avif) | +| ![Screenshot of Nexus Client in dark mode, showing users talking, with a sidebar showing rooms and spaces, and another sidebar showing members](./assets/screenshots/desktop-dark.png) | ![The same screenshot as above, but in light mode](./assets/screenshots/desktop-light.png) | ## Try it out diff --git a/assets/screenshotDark.avif b/assets/screenshotDark.avif deleted file mode 100644 index c1a8e9f..0000000 Binary files a/assets/screenshotDark.avif and /dev/null differ diff --git a/assets/screenshotLight.avif b/assets/screenshotLight.avif deleted file mode 100644 index c26c0b7..0000000 Binary files a/assets/screenshotLight.avif and /dev/null differ diff --git a/assets/screenshots/desktop-dark.png b/assets/screenshots/desktop-dark.png new file mode 100644 index 0000000..e9753bd Binary files /dev/null and b/assets/screenshots/desktop-dark.png differ diff --git a/assets/screenshots/desktop-light.png b/assets/screenshots/desktop-light.png new file mode 100644 index 0000000..2d5c876 Binary files /dev/null and b/assets/screenshots/desktop-light.png differ diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt new file mode 100644 index 0000000..e310d29 --- /dev/null +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -0,0 +1,10 @@ +

Nexus is a simple and user-friendly client for Matrix, the open and decentralized chat network. It's made with Flutter, with gomuks handling the Matrix side.

+ +

Features include:

+ diff --git a/fastlane/metadata/android/en-US/images/icon.png b/fastlane/metadata/android/en-US/images/icon.png new file mode 100644 index 0000000..6b1b81c Binary files /dev/null and b/fastlane/metadata/android/en-US/images/icon.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png new file mode 100644 index 0000000..c6688a0 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png new file mode 100644 index 0000000..1d4ff07 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png new file mode 100644 index 0000000..f969bf1 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png new file mode 100644 index 0000000..82915b6 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png differ diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt new file mode 100644 index 0000000..570387a --- /dev/null +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -0,0 +1 @@ +A simple and user-friendly Matrix client diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt new file mode 100644 index 0000000..755a6d7 --- /dev/null +++ b/fastlane/metadata/android/en-US/title.txt @@ -0,0 +1 @@ +Nexus diff --git a/integration_test/helpers/pump_until.dart b/integration_test/helpers/pump_until.dart index 5ae8358..95c4944 100644 --- a/integration_test/helpers/pump_until.dart +++ b/integration_test/helpers/pump_until.dart @@ -10,9 +10,23 @@ extension PumpUntil on WidgetTester { Future pumpUntil(Finder finder) async { const timeout = Duration(seconds: 30); - String textsIn(Finder finder) => widgetList( - find.descendant(of: finder, matching: find.byType(Text)), - ).map((text) => text.data).nonNulls.join("\n"); + String textsIn(Finder finder) => + widgetList( + find.descendant( + of: finder, + matching: find.byWidgetPredicate( + (widget) => widget is Text || widget is SelectableText, + ), + ), + ) + .map( + (widget) => switch (widget) { + Text(:final data) || SelectableText(:final data) => data, + _ => null, + }, + ) + .nonNulls + .join("\n"); final stopwatch = Stopwatch()..start(); while (stopwatch.elapsed < timeout) { diff --git a/integration_test/screenshots/avatars/SOURCES.md b/integration_test/screenshots/avatars/SOURCES.md new file mode 100644 index 0000000..e6094db --- /dev/null +++ b/integration_test/screenshots/avatars/SOURCES.md @@ -0,0 +1,22 @@ +# Screenshot avatars + +Avatars for the people, rooms and spaces in the screenshots, cropped from photos on Wikimedia Commons and Public Domain Pictures that are in the public domain or CC0, so they need no attribution. + +| File | License | Source | +| ---------------------- | ------------- | -------------------------------------------------------------------------------------------------------- | +| pumpkin.webp | Public domain | https://commons.wikimedia.org/wiki/File:Cucurbita_2011_G1.jpg | +| orange.webp | Public domain | https://commons.wikimedia.org/wiki/File:Orange-fruit-2.jpg | +| log-1.webp | CC0 | https://commons.wikimedia.org/wiki/File:Tabby_cat_with_blue_eyes-3336579.jpg (tinted green) | +| strudel.webp | CC0 | https://commons.wikimedia.org/wiki/File:Strudel-de-maca-2.png | +| opal.webp | Public domain | https://commons.wikimedia.org/wiki/File:Opal_6.jpg | +| cinnamon.webp | CC0 | https://commons.wikimedia.org/wiki/File:Cassia_bark_(2).jpg | +| green-tea.webp | CC0 | https://www.publicdomainpictures.net/en/view-image.php?image=165622&picture=hot-green-tea-with-heart-art | +| development.webp | CC0 | https://commons.wikimedia.org/wiki/File:Code_on_computer_monitor_(Unsplash).jpg | +| fediverse.webp | Public domain | https://commons.wikimedia.org/wiki/File:Blackeyegalaxy.jpg | +| flutter-devs.webp | CC0 | https://commons.wikimedia.org/wiki/File:Anna%27s_Hummingbird_Feeding.jpg | +| linux-mobile.webp | CC0 | https://commons.wikimedia.org/wiki/File:Gentoo_penguin_with_a_feather_in_its_beak.jpg | +| matrix-community.webp | Public domain | https://commons.wikimedia.org/wiki/File:Earth_at_Night,_Asia_(8270740728).jpg | +| off-topic.webp | CC0 | https://commons.wikimedia.org/wiki/File:Coffee_cup_surrounded_by_coffee_beans.jpg | +| open-source-games.webp | Public domain | https://commons.wikimedia.org/wiki/File:LodgeNet-Nintendo-N64-Controller.jpg | +| rust.webp | CC0 | https://commons.wikimedia.org/wiki/File:Carcinus_maenas_146434844.jpg | +| self-hosting.webp | CC0 | https://commons.wikimedia.org/wiki/File:Legrand_S.A._server_rack.jpg | diff --git a/integration_test/screenshots/avatars/cinnamon.webp b/integration_test/screenshots/avatars/cinnamon.webp new file mode 100644 index 0000000..8520f24 Binary files /dev/null and b/integration_test/screenshots/avatars/cinnamon.webp differ diff --git a/integration_test/screenshots/avatars/development.webp b/integration_test/screenshots/avatars/development.webp new file mode 100644 index 0000000..21a47b1 Binary files /dev/null and b/integration_test/screenshots/avatars/development.webp differ diff --git a/integration_test/screenshots/avatars/fediverse.webp b/integration_test/screenshots/avatars/fediverse.webp new file mode 100644 index 0000000..8952d99 Binary files /dev/null and b/integration_test/screenshots/avatars/fediverse.webp differ diff --git a/integration_test/screenshots/avatars/flutter-devs.webp b/integration_test/screenshots/avatars/flutter-devs.webp new file mode 100644 index 0000000..79634a2 Binary files /dev/null and b/integration_test/screenshots/avatars/flutter-devs.webp differ diff --git a/integration_test/screenshots/avatars/green-tea.webp b/integration_test/screenshots/avatars/green-tea.webp new file mode 100644 index 0000000..d58801c Binary files /dev/null and b/integration_test/screenshots/avatars/green-tea.webp differ diff --git a/integration_test/screenshots/avatars/linux-mobile.webp b/integration_test/screenshots/avatars/linux-mobile.webp new file mode 100644 index 0000000..8a525bd Binary files /dev/null and b/integration_test/screenshots/avatars/linux-mobile.webp differ diff --git a/integration_test/screenshots/avatars/log-1.webp b/integration_test/screenshots/avatars/log-1.webp new file mode 100644 index 0000000..e954d0c Binary files /dev/null and b/integration_test/screenshots/avatars/log-1.webp differ diff --git a/integration_test/screenshots/avatars/matrix-community.webp b/integration_test/screenshots/avatars/matrix-community.webp new file mode 100644 index 0000000..2e58828 Binary files /dev/null and b/integration_test/screenshots/avatars/matrix-community.webp differ diff --git a/integration_test/screenshots/avatars/off-topic.webp b/integration_test/screenshots/avatars/off-topic.webp new file mode 100644 index 0000000..ea9ec17 Binary files /dev/null and b/integration_test/screenshots/avatars/off-topic.webp differ diff --git a/integration_test/screenshots/avatars/opal.webp b/integration_test/screenshots/avatars/opal.webp new file mode 100644 index 0000000..807223f Binary files /dev/null and b/integration_test/screenshots/avatars/opal.webp differ diff --git a/integration_test/screenshots/avatars/open-source-games.webp b/integration_test/screenshots/avatars/open-source-games.webp new file mode 100644 index 0000000..3b92ac7 Binary files /dev/null and b/integration_test/screenshots/avatars/open-source-games.webp differ diff --git a/integration_test/screenshots/avatars/orange.webp b/integration_test/screenshots/avatars/orange.webp new file mode 100644 index 0000000..1f9c199 Binary files /dev/null and b/integration_test/screenshots/avatars/orange.webp differ diff --git a/integration_test/screenshots/avatars/pumpkin.webp b/integration_test/screenshots/avatars/pumpkin.webp new file mode 100644 index 0000000..d300ec9 Binary files /dev/null and b/integration_test/screenshots/avatars/pumpkin.webp differ diff --git a/integration_test/screenshots/avatars/quadradical.webp b/integration_test/screenshots/avatars/quadradical.webp new file mode 100644 index 0000000..79e26c1 Binary files /dev/null and b/integration_test/screenshots/avatars/quadradical.webp differ diff --git a/integration_test/screenshots/avatars/rust.webp b/integration_test/screenshots/avatars/rust.webp new file mode 100644 index 0000000..5089187 Binary files /dev/null and b/integration_test/screenshots/avatars/rust.webp differ diff --git a/integration_test/screenshots/avatars/self-hosting.webp b/integration_test/screenshots/avatars/self-hosting.webp new file mode 100644 index 0000000..30535e9 Binary files /dev/null and b/integration_test/screenshots/avatars/self-hosting.webp differ diff --git a/integration_test/screenshots/avatars/strudel.webp b/integration_test/screenshots/avatars/strudel.webp new file mode 100644 index 0000000..e69e842 Binary files /dev/null and b/integration_test/screenshots/avatars/strudel.webp differ diff --git a/integration_test/screenshots/screenshot_data.dart b/integration_test/screenshots/screenshot_data.dart new file mode 100644 index 0000000..fbabe4b --- /dev/null +++ b/integration_test/screenshots/screenshot_data.dart @@ -0,0 +1,365 @@ +import "dart:convert"; + +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:nexus/models/client_state.dart"; +import "package:nexus/models/room.dart"; +import "package:nexus/models/space_edge.dart"; + +/// What the screenshots show: the Nexus space, with its room mid-conversation. +/// Rooms are written as gomuks reports them. +class ScreenshotData(final DateTime now) { + static const me = "@quadradical:federated.nexus"; + static const bot = "@nexusbot:federated.nexus"; + static const alice = "@alice:example.org"; + static const pumpkin = "@pumpkin:example.org"; + static const strudel = "@strudel:example.org"; + static const opal = "@opal:example.org"; + static const greenTea = "@greentea:example.org"; + + static const nexusSpace = "!nexus-space:federated.nexus"; + static const nexusRoom = "!nexus-client:federated.nexus"; + + /// Nexus' own avatar, the app icon. Every other `mxc://screenshots/` is + /// a file in avatars/, see avatars/SOURCES.md. + static const nexusAvatar = "mxc://screenshots/nexus"; + static const myAvatar = "mxc://screenshots/quadradical.webp"; + static const issueUrl = "https://git.federated.nexus/Nexus/nexus/issues/174"; + static const commitUrl = + "https://git.federated.nexus/Nexus/nexus/commit/41f5225420"; + + static const topic = + "A simple and user-friendly Matrix client made with Flutter and a Gomuks backend."; + + /// The users shown, with their power level in the Nexus room. Those without + /// an avatar get Nexus' fallback. Everyone but QuadRadical and NexusBot is + /// made up, on example.org so as not to be anyone real. + static const users = IListConst([ + (id: me, name: "QuadRadical", powerLevel: 100, avatar: myAvatar), + (id: alice, name: "Alice", powerLevel: 100, avatar: null), + (id: bot, name: "NexusBot", powerLevel: 50, avatar: nexusAvatar), + ( + id: pumpkin, + name: "Pumpkin", + powerLevel: 0, + avatar: "mxc://screenshots/pumpkin.webp", + ), + ( + id: strudel, + name: "Strudel", + powerLevel: 0, + avatar: "mxc://screenshots/strudel.webp", + ), + ( + id: opal, + name: "Opal", + powerLevel: 0, + avatar: "mxc://screenshots/opal.webp", + ), + ( + id: "@orange:example.org", + name: "Orange", + powerLevel: 0, + avatar: "mxc://screenshots/orange.webp", + ), + ( + id: "@log1:example.org", + name: "log(1)", + powerLevel: 0, + avatar: "mxc://screenshots/log-1.webp", + ), + ( + id: greenTea, + name: "Green Tea", + powerLevel: 0, + avatar: "mxc://screenshots/green-tea.webp", + ), + ( + id: "@cinnamon:example.org", + name: "Cinnamon", + powerLevel: 0, + avatar: "mxc://screenshots/cinnamon.webp", + ), + ]); + + /// The Nexus space's other rooms + static const otherRooms = IListConst([ + (name: "Off-topic", unread: 3), + (name: "Development", unread: 0), + ]); + + /// Spaces shown in the rail besides Nexus, so it doesn't look empty + static const otherSpaces = IListConst([ + "Matrix Community", + "Flutter Devs", + "Linux Mobile", + "Rust", + "Open Source Games", + "Fediverse", + "Self-Hosting", + ]); + + static const clientState = ClientState( + isInitialized: true, + isLoggedIn: true, + isVerified: true, + userId: me, + deviceId: "SCREENSHOTS", + homeserverUrl: "https://federated.nexus", + ); + + static final IList topLevelSpaces = .new([ + nexusSpace, + ...otherSpaces.map(_roomId), + ]); + + static final IMap> spaceEdges = .new({ + nexusSpace: .new( + [ + nexusRoom, + ...otherRooms.map((room) => _roomId(room.name)), + ].map((id) => .new(childId: id)), + ), + }); + + /// The rooms, with the Nexus room's state already fetched so it isn't + /// requested + late final IMap rooms = { + nexusSpace: _room(nexusSpace, "Nexus Client", topic: topic), + nexusRoom: _room( + nexusRoom, + "Nexus Client", + topic: topic, + lastEvent: timeline.last, + extra: { + "timeline": timeline + .map( + (event) => { + "timeline_rowid": event["rowid"], + "event_rowid": event["rowid"], + }, + ) + .toList(), + "events": [...nexusState, ...timeline], + "state": _stateIndex(nexusState), + "receipts": { + timeline.last["event_id"]: [ + _receipt(strudel, timeline.last), + _receipt(greenTea, timeline.last), + ], + timeline[timeline.length - 2]["event_id"]: [ + _receipt(opal, timeline[timeline.length - 2]), + ], + }, + "has_more": false, + }, + ).copyWith(hasFetchedState: true, hasFetchedMembers: true), + for (final room in otherRooms) + _roomId(room.name): _room( + _roomId(room.name), + room.name, + avatar: _avatarOfRoom(room.name), + unread: room.unread, + ), + for (final space in otherSpaces) + _roomId(space): _room( + _roomId(space), + space, + avatar: _avatarOfRoom(space), + ), + }.lock; + + /// The Nexus room's state: who's in it, and their power levels + late final List> nexusState = [ + _event(me, "m.room.power_levels", { + "users": Map.fromEntries( + users.map((user) => .new(user.id, user.powerLevel)), + ), + "users_default": 0, + "events_default": 0, + "state_default": 50, + }, key: ""), + ...users.map( + (user) => _event(user.id, "m.room.member", { + "membership": "join", + "displayname": user.name, + "avatar_url": ?user.avatar, + }, key: user.id), + ), + ]; + + late final List> timeline = [ + _message( + alice, + 35, + "the screenshots in the readme are pretty out of date btw", + ), + _message( + me, + 32, + "Should I:\n" + "1. just take screenshots on a few devices, and redo them whenever the " + "UI changes\n" + "or\n" + "2. overengineer a test that makes them automatically, in a fake room\n" + "I think the answer is 2 :p", + html: + "Should I:" + "
  1. just take screenshots on a few devices, and redo them " + "whenever the UI changes
" + "or" + '
  1. overengineer a test that makes them ' + "automatically, in a fake room
" + "I think the answer is 2 :p", + reactions: {"2️⃣": 3}, + ), + _message(pumpkin, 30, "2, obviously"), + // Linked only in the HTML, so the body has no link to preview + _message( + bot, + 29, + "[Nexus/nexus] Issue opened: #174 Automatic scripts to make screenshots " + "for different pages and viewports by Henry-Hiles", + html: + '[Nexus/nexus] Issue opened: #174 Automatic ' + "scripts to make screenshots for different pages and viewports " + "by Henry-Hiles", + ), + _message( + bot, + 7, + "[Nexus/nexus] Henry-Hiles pushed 1 commit to main: $commitUrl: " + "update screenshots - Henry-Hiles", + html: + "[Nexus/nexus] Henry-Hiles pushed 1 commit to main:
" + '41f5225: update screenshots - Henry-Hiles', + reactions: {"🚀": 3}, + ), + _message( + pumpkin, + 5, + "they're generated now?? let's gooo", + reactions: {"🎉": 2, "❤️": 1}, + ), + ]; + + /// The link preview shown under NexusBot's message + static const urlPreview = { + "og:title": "update screenshots · 41f5225420", + "og:description": + "nexus - A simple and user-friendly Matrix client made with Flutter " + "and a Gomuks backend.", + }; + + int _rowId = 0; + + /// An event in the Nexus room + Map _event( + String sender, + String type, + Map content, { + String? key, + int minutesAgo = 60, + Map reactions = const {}, + }) { + final rowId = ++_rowId; + return { + "rowid": rowId, + "timeline_rowid": rowId, + "room_id": nexusRoom, + "event_id": "\$screenshots-$rowId", + "sender": sender, + "type": type, + "state_key": ?key, + "timestamp": now + .subtract(.new(minutes: minutesAgo)) + .millisecondsSinceEpoch, + "content": content, + "reactions": reactions, + }; + } + + Map _message( + String sender, + int minutesAgo, + String body, { + String? html, + Map reactions = const {}, + }) => _event( + sender, + "m.room.message", + { + "msgtype": sender == bot ? "m.notice" : "m.text", + "body": body, + if (html != null) ...{ + "format": "org.matrix.custom.html", + "formatted_body": html, + }, + }, + minutesAgo: minutesAgo, + reactions: reactions, + ); + + Map _receipt(String userId, Map event) => { + "user_id": userId, + "event_id": event["event_id"], + "room_id": nexusRoom, + "timestamp": event["timestamp"], + }; + + /// A room, with [extra] JSON besides its metadata + Room _room( + String id, + String name, { + String avatar = nexusAvatar, + String? topic, + int unread = 0, + Map? lastEvent, + Map extra = const {}, + }) => .fromJson( + // Round-tripped, so the JSON has the types gomuks' decoded JSON would + jsonDecode( + jsonEncode({ + "meta": { + "room_id": id, + "name": name, + "avatar": avatar, + "topic": ?topic, + "has_member_list": true, + "preview_event_rowid": lastEvent?["rowid"] ?? 0, + "sorting_timestamp": + lastEvent?["timestamp"] ?? + now.subtract(const .new(hours: 1)).millisecondsSinceEpoch, + "unread_highlights": 0, + "unread_notifications": 0, + "unread_messages": unread, + }, + ...extra, + }), + ), + ); + + static Map> _stateIndex( + List> state, + ) => state + .groupListsBy((event) => event["type"] as String) + .map( + (type, events) => .new( + type, + Map.fromEntries( + events.map( + (event) => + .new(event["state_key"] as String, event["rowid"] as int), + ), + ), + ), + ); + + static String _avatarOfRoom(String name) => + "mxc://screenshots/${_slug(name)}.webp"; + + static String _slug(String name) => + name.toLowerCase().replaceAll(RegExp("[^a-z]+"), "-"); + + static String _roomId(String name) => "!${_slug(name)}:federated.nexus"; +} diff --git a/integration_test/screenshots/screenshot_fakes.dart b/integration_test/screenshots/screenshot_fakes.dart new file mode 100644 index 0000000..23ef76f --- /dev/null +++ b/integration_test/screenshots/screenshot_fakes.dart @@ -0,0 +1,62 @@ +import "dart:io"; +import "dart:ui" as ui; + +import "package:flutter/rendering.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:nexus/controllers/client.dart"; + +import "screenshot_data.dart"; + +/// Answers the commands the screenshotted screens send to gomuks, so the +/// screenshots don't need gomuks or a homeserver. +class FakeClientController() extends ClientController { + this : super(null); + + @override + Future build() async => 0; + + @override + Future sendCommand( + String command, [ + Map request = const {}, + ]) async => switch (command) { + "get_url_preview" => ScreenshotData.urlPreview, + "download_media" => {"path": _mediaPath(request["mxc"])}, + "get_related_events" => [], + "mark_read" || "set_typing" => null, + _ => throw UnimplementedError("The screenshots can't answer $command"), + }; + + /// The file for [mxc], from avatars/ beside this file + static String _mediaPath(String mxc) { + final file = File(switch (mxc) { + ScreenshotData.nexusAvatar => "assets/mobile.png", + _ => + "integration_test/screenshots/avatars/" + "${Uri.parse(mxc).pathSegments.last}", + }); + if (!file.existsSync()) throw StateError("No screenshot media for $mxc"); + return file.absolute.path; + } +} + +extension Screenshot on WidgetTester { + /// Saves what the app currently shows to [path], as a PNG. + Future screenshot(String path) async { + // Lets images finish decoding and animations settle + for (var frame = 0; frame < 20; frame++) { + await runAsync(() => .delayed(const .new(milliseconds: 50))); + await pump(const .new(milliseconds: 50)); + } + + final renderView = binding.renderViews.single; + final layer = renderView.debugLayer! as OffsetLayer; + final bytes = await runAsync(() async { + final image = await layer.toImage(Offset.zero & view.physicalSize); + return image.toByteData(format: ui.ImageByteFormat.png); + }); + + await File(path).create(recursive: true); + await File(path).writeAsBytes(bytes!.buffer.asUint8List()); + } +} diff --git a/integration_test/screenshots/screenshots_test.dart b/integration_test/screenshots/screenshots_test.dart new file mode 100644 index 0000000..29ed0bd --- /dev/null +++ b/integration_test/screenshots/screenshots_test.dart @@ -0,0 +1,142 @@ +import "dart:convert"; + +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/gomuks_listener.dart"; +import "package:nexus/controllers/init_complete.dart"; +import "package:nexus/controllers/key.dart"; +import "package:nexus/controllers/member_list_opened.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/settings.dart"; +import "package:nexus/controllers/space_edges.dart"; +import "package:nexus/controllers/top_level_spaces.dart"; +import "package:nexus/main.dart" as nexus; +import "package:nexus/models/account.dart"; +import "package:nexus/widgets/account_scope.dart"; +import "package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart"; +import "package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart"; + +import "../helpers/pump_until.dart"; +import "screenshot_data.dart"; +import "screenshot_fakes.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + // Where the screenshots are saved, set by `nix run .#screenshots` + const outputDirectory = String.fromEnvironment( + "SCREENSHOTS_DIR", + defaultValue: "build/screenshots", + ); + const accountId = "screenshots"; + + final data = ScreenshotData(.now()); + + setUpAll(() => nexus.isInBackground = false); + + /// Opens Nexus at the Nexus room, as a [size] window at [pixelRatio], + /// pretending to be a phone if [mobile]. + Future open( + WidgetTester tester, { + required ThemeMode theme, + required Size size, + required double pixelRatio, + required bool mobile, + }) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = pixelRatio; + addTearDown(tester.view.reset); + + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.withData({ + KeyController.accountsKey: json.encode([ + Account( + id: accountId, + userId: ScreenshotData.me, + displayName: "QuadRadical", + avatar: .parse(ScreenshotData.myAvatar), + ), + ]), + KeyController.activeAccountKey: accountId, + MemberListOpenedController.key: true, + "$accountId/${KeyController.spaceKey}": ScreenshotData.nexusSpace, + "$accountId/${KeyController.roomKey}": ScreenshotData.nexusRoom, + }); + + await tester.pumpWidget( + AccountScope( + initialAccountId: accountId, + retry: (_, _) => null, + overrides: [ + ClientController.provider.overrideWith(FakeClientController.new), + // Never starts gomuks; the state below is what it would sync + GomuksListenerController.provider.overrideWithBuild((_, _) {}), + ClientStateController.provider.overrideWithBuild( + (_, _) => ScreenshotData.clientState, + ), + InitCompleteController.provider.overrideWithBuild((_, _) => true), + RoomsController.provider.overrideWithBuild((_, _) => data.rooms), + TopLevelSpacesController.provider.overrideWithBuild( + (_, _) => ScreenshotData.topLevelSpaces, + ), + SpaceEdgesController.provider.overrideWithBuild( + (_, _) => ScreenshotData.spaceEdges, + ), + SettingsController.provider.overrideWithBuild( + (_, _) => .new( + theme: theme, + useDynamicTheming: false, + linuxMobileMode: mobile, + ), + ), + ], + child: nexus.App(), + ), + ); + + await tester.pumpUntil(find.text("they're generated now?? let's gooo")); + + // Focused on desktop, which would show a cursor + FocusManager.instance.primaryFocus?.unfocus(); + } + + for (final theme in [ThemeMode.dark, ThemeMode.light]) { + testWidgets("desktop, ${theme.name}", (tester) async { + await open( + tester, + theme: theme, + size: const .new(1920, 1080), + pixelRatio: 1, + mobile: false, + ); + + await tester.pumpUntil(find.text("Strudel")); + + await tester.screenshot("$outputDirectory/desktop-${theme.name}.png"); + }); + + testWidgets("phone, ${theme.name}", (tester) async { + await open( + tester, + theme: theme, + size: const .new(1080, 2340), + pixelRatio: 3, + mobile: true, + ); + + await tester.screenshot("$outputDirectory/phone-chat-${theme.name}.png"); + + await tester.tap( + find.byTooltip( + const DefaultMaterialLocalizations().openAppDrawerTooltip, + ), + ); + await tester.pumpUntil(find.text("Off-topic")); + + await tester.screenshot("$outputDirectory/phone-rooms-${theme.name}.png"); + }); + } +} diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index e6afa3b..3a1ac6c 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -221,7 +221,10 @@ class ClientController(final String? accountId) extends AsyncNotifier { : .fromJson(response["notification"]); } - Future _sendCommand( + /// Sends [command] to gomuks, returning its decoded response. Overridable so + /// that tests can answer commands without gomuks. + @protected + Future sendCommand( String command, [ Map data = const {}, ]) => callGomuksMethod(data, (handle, data, callback) { @@ -238,18 +241,18 @@ class ClientController(final String? accountId) extends AsyncNotifier { }); Future redactEvent(RedactEventRequest report) => - _sendCommand("redact_event", report.toJson()); + sendCommand("redact_event", report.toJson()); /// Null for gomuks' own commands that don't reply. Future sendMessage(SendMessageRequest request) async => - switch (await _sendCommand("send_message", request.toJson())) { + switch (await sendCommand("send_message", request.toJson())) { final Map json => .fromJson(json), _ => null, }; /// The MSC4391 definitions of gomuks' own commands. Future> getCommandDefinitions() async => - switch (await _sendCommand("get_command_definitions")) { + switch (await sendCommand("get_command_definitions")) { final List definitions => .new( definitions.whereType>().map( BotCommandDescriptionContent.fromJson, @@ -259,27 +262,27 @@ class ClientController(final String? accountId) extends AsyncNotifier { }; Future sendEvent(SendEventRequest request) async => - Event.fromJson(await _sendCommand("send_event", request.toJson())); + Event.fromJson(await sendCommand("send_event", request.toJson())); Future setState(SetStateRequest request) async => - await _sendCommand("set_state", request.toJson()); + await sendCommand("set_state", request.toJson()); - Future pauseSync() => _sendCommand("pause_sync"); - Future resumeSync() => _sendCommand("resume_sync"); + Future pauseSync() => sendCommand("pause_sync"); + Future resumeSync() => sendCommand("resume_sync"); Future verify(String recoveryKey) => - _sendCommand("verify", {"recovery_key": recoveryKey}); + sendCommand("verify", {"recovery_key": recoveryKey}); Future generateRecoveryKey(String passphrase) async => .fromJson( - await _sendCommand("generate_recovery_key", {"passphrase": passphrase}), + await sendCommand("generate_recovery_key", {"passphrase": passphrase}), ); Future resetEncryption(RecoveryKey key) => - _sendCommand("reset_encryption", key.toJson()); + sendCommand("reset_encryption", key.toJson()); Future getCrossSigningResetUrl(Uri homeserver) async { const action = "org.matrix.cross_signing_reset"; - final oauth = (await _sendCommand("get_login_flows", { + final oauth = (await sendCommand("get_login_flows", { "homeserver_url": homeserver.toString(), }))["oauth"]; @@ -297,33 +300,33 @@ class ClientController(final String? accountId) extends AsyncNotifier { } Future joinRoom(JoinRoomRequest request) async => - (await _sendCommand("join_room", request.toJson()))["room_id"]; + (await sendCommand("join_room", request.toJson()))["room_id"]; Future knockRoom(JoinRoomRequest request) async => - (await _sendCommand("knock_room", request.toJson()))["room_id"]; + (await sendCommand("knock_room", request.toJson()))["room_id"]; Future createRoom(CreateRoomRequest request) async => - (await _sendCommand("create_room", request.toJson()))["room_id"]; + (await sendCommand("create_room", request.toJson()))["room_id"]; Future getSpaceHierarchy( GetSpaceHierarchyRequest request, ) async => - .fromJson(await _sendCommand("get_space_hierarchy", request.toJson())); + .fromJson(await sendCommand("get_space_hierarchy", request.toJson())); Future getRoomDirectory( GetRoomDirectoryRequest request, ) async => - .fromJson(await _sendCommand("get_room_directory", request.toJson())); + .fromJson(await sendCommand("get_room_directory", request.toJson())); Future getRoomSummary(JoinRoomRequest request) async => - .fromJson(await _sendCommand("get_room_summary", request.toJson())); + .fromJson(await sendCommand("get_room_summary", request.toJson())); Future leaveRoom(String roomId) => - _sendCommand("leave_room", {"room_id": roomId}); + sendCommand("leave_room", {"room_id": roomId}); Future> getRoomState(GetRoomStateRequest request) async { Future getState(GetRoomStateRequest request) async => - (await _sendCommand("get_room_state", request.toJson())) as List?; + (await sendCommand("get_room_state", request.toJson())) as List?; final response = await getState(request); return .new( @@ -336,7 +339,7 @@ class ClientController(final String? accountId) extends AsyncNotifier { Future> getSpecificRoomState( GetSpecificRoomStateRequest request, ) async => .new( - ((await _sendCommand("get_specific_room_state", request.toJson())) + ((await sendCommand("get_specific_room_state", request.toJson())) as List? ?? []) .map((event) => .fromJson(event)), @@ -346,48 +349,48 @@ class ClientController(final String? accountId) extends AsyncNotifier { GetRelatedEventsRequest request, ) async { final response = - (await _sendCommand("get_related_events", request.toJson())) as List?; + (await sendCommand("get_related_events", request.toJson())) as List?; return .new(response?.map((event) => .fromJson(event))); } Future getMentions(GetMentionsRequest request) async => - .fromJson(await _sendCommand("get_mentions", request.toJson())); + .fromJson(await sendCommand("get_mentions", request.toJson())); Future getEvent(GetEventRequest request) async { - final json = await _sendCommand("get_event", request.toJson()); + final json = await sendCommand("get_event", request.toJson()); return json == null ? null : .fromJson(json); } Future getUrlPreview(Uri url) async => - .fromJson(await _sendCommand("get_url_preview", {"url": url.toString()})); + .fromJson(await sendCommand("get_url_preview", {"url": url.toString()})); Future paginate(PaginateRequest request) async => - .fromJson(await _sendCommand("paginate", request.toJson())); + .fromJson(await sendCommand("paginate", request.toJson())); Future getThreads(GetThreadsRequest request) async => - .fromJson(await _sendCommand("get_threads", request.toJson())); + .fromJson(await sendCommand("get_threads", request.toJson())); Future paginateManual(PaginateManualRequest request) async => - .fromJson(await _sendCommand("paginate_manual", request.toJson())); + .fromJson(await sendCommand("paginate_manual", request.toJson())); Future searchLocal(SearchRequest request) async => - .fromJson(await _sendCommand("search_local", request.toJson())); + .fromJson(await sendCommand("search_local", request.toJson())); Future searchServer(SearchRequest request) async => - .fromJson(await _sendCommand("search_server", request.toJson())); + .fromJson(await sendCommand("search_server", request.toJson())); Future getEventContext(GetEventContextRequest request) async => - .fromJson(await _sendCommand("get_event_context", request.toJson())); + .fromJson(await sendCommand("get_event_context", request.toJson())); Future getProfile(String userId) async { try { - return .fromJson(await _sendCommand("get_profile", {"user_id": userId})); + return .fromJson(await sendCommand("get_profile", {"user_id": userId})); } catch (_) { return .new(profile: .new(id: userId)); } } - Future setProfileField(String field, Object? value) => _sendCommand( + Future setProfileField(String field, Object? value) => sendCommand( "set_profile_field", { "field": field, @@ -397,28 +400,28 @@ class ClientController(final String? accountId) extends AsyncNotifier { ); Future reportEvent(ReportRequest request) => - _sendCommand("report_event", request.toJson()); + sendCommand("report_event", request.toJson()); Future setMembership(SetMembershipRequest request) => - _sendCommand("set_membership", request.toJson()); + sendCommand("set_membership", request.toJson()); Future setAccountData(SetAccountDataRequest request) => - _sendCommand("set_account_data", request.toJson()); + sendCommand("set_account_data", request.toJson()); Future updatePushRule(UpdatePushRuleRequest request) => - _sendCommand("update_push_rule", request.toJson()); + sendCommand("update_push_rule", request.toJson()); Future registerPusher(RegisterPusherRequest request) => - _sendCommand("register_homeserver_push", request.toJson()); + sendCommand("register_homeserver_push", request.toJson()); Future deregisterPusher(DeregisterPusherRequest request) => - _sendCommand("register_homeserver_push", { + sendCommand("register_homeserver_push", { ...request.toJson(), "kind": null, }); Future uploadMedia(UploadMediaRequest request) async => - .fromJson(await _sendCommand("upload_media", request.toJson())); + .fromJson(await sendCommand("upload_media", request.toJson())); Future downloadMedia(DownloadMediaRequest request) async { if (accountId == null && @@ -427,13 +430,13 @@ class ClientController(final String? accountId) extends AsyncNotifier { } return .new( - (await _sendCommand("download_media", request.toJson()))["path"], + (await sendCommand("download_media", request.toJson()))["path"], ); } - Future logout() => _sendCommand("logout"); + Future logout() => sendCommand("logout"); - Future setTyping(String roomId, Duration timeout) => _sendCommand( + Future setTyping(String roomId, Duration timeout) => sendCommand( "set_typing", {"room_id": roomId, "timeout": timeout.inMilliseconds}, ); @@ -462,7 +465,7 @@ class ClientController(final String? accountId) extends AsyncNotifier { ref.read(SettingsController.provider).value?.readReceipts == .showAndSend; - await _sendCommand("mark_read", { + await sendCommand("mark_read", { "room_id": room.metadata!.id, "receipt_type": sendPublic ? "m.read" : "m.read.private", "event_id": event.eventId, @@ -471,44 +474,44 @@ class ClientController(final String? accountId) extends AsyncNotifier { } Future registerClient(OAuthRegisterClientRequest request) async => - (await _sendCommand( + (await sendCommand( "oauth_register_client", request.toJson(), ))["client_id"]; Future getAuthUrl(OAuthGetAuthUrl request) async => .fromJson( - await _sendCommand("oauth_get_authorization_url", request.toJson()), + await sendCommand("oauth_get_authorization_url", request.toJson()), ); Future exchangeToken(OAuthExchangeTokenRequest request) async => - await _sendCommand("oauth_exchange_token", request.toJson()); + await sendCommand("oauth_exchange_token", request.toJson()); Future> getMutualRooms(String userId) async => IList( - ((await _sendCommand("get_mutual_rooms", {"user_id": userId}))["joined"] + ((await sendCommand("get_mutual_rooms", {"user_id": userId}))["joined"] as List?) ?.cast(), ); Future> getLocalAliases(String roomId) async => IList( - ((await _sendCommand("get_local_aliases", {"room_id": roomId}))["aliases"] + ((await sendCommand("get_local_aliases", {"room_id": roomId}))["aliases"] as List?) ?.cast(), ); Future createAlias(String roomId, String alias) => - _sendCommand("create_alias", {"room_id": roomId, "alias": alias}); + sendCommand("create_alias", {"room_id": roomId, "alias": alias}); Future deleteAlias(String alias) => - _sendCommand("delete_alias", {"alias": alias}); + sendCommand("delete_alias", {"alias": alias}); Future getRoomPublished(String roomId) async => - (await _sendCommand("get_room_visibility", { + (await sendCommand("get_room_visibility", { "room_id": roomId, }))["visibility"] == "public"; - Future setRoomPublished(String roomId, bool published) => _sendCommand( + Future setRoomPublished(String roomId, bool published) => sendCommand( "publish_room", {"room_id": roomId, "visibility": published ? "public" : "private"}, ); @@ -517,7 +520,7 @@ class ClientController(final String? accountId) extends AsyncNotifier { String roomId, String version, { ISet additionalCreators = const .empty(), - }) async => (await _sendCommand("upgrade_room", { + }) async => (await sendCommand("upgrade_room", { "room_id": roomId, "new_version": version, if (additionalCreators.isNotEmpty) @@ -527,7 +530,7 @@ class ClientController(final String? accountId) extends AsyncNotifier { /// The room [alias] points to, or null if it doesn't exist. Future resolveAlias(String alias) async { try { - return (await _sendCommand("resolve_alias", {"alias": alias}))["room_id"]; + return (await sendCommand("resolve_alias", {"alias": alias}))["room_id"]; } on GomuksException catch (error) { if (error.statusCode == 404) return null; rethrow; @@ -535,14 +538,14 @@ class ClientController(final String? accountId) extends AsyncNotifier { } Future getSpecVersions() async => - .fromJson(await _sendCommand("get_versions")); + .fromJson(await sendCommand("get_versions")); Future getCapabilities() async => - .fromJson((await _sendCommand("get_capabilities"))["capabilities"]); + .fromJson((await sendCommand("get_capabilities"))["capabilities"]); Future discoverHomeserver(Uri homeserver) async { try { - final response = await _sendCommand("discover_homeserver", { + final response = await sendCommand("discover_homeserver", { "user_id": "@fake-user:${homeserver.authority}", }); return Uri.parse(response["m.homeserver"]?["base_url"]); diff --git a/lib/widgets/account_scope.dart b/lib/widgets/account_scope.dart index 3f89b4b..ff8db09 100644 --- a/lib/widgets/account_scope.dart +++ b/lib/widgets/account_scope.dart @@ -1,5 +1,6 @@ import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:flutter_riverpod/misc.dart"; import "package:material_ui/material_ui.dart"; import "package:nexus/controllers/active_account.dart"; import "package:nexus/controllers/notification_request.dart"; @@ -11,6 +12,9 @@ class const AccountScope({ required final Widget child, final List? observers, final Duration? Function(int retryCount, Object error)? retry, + + /// Applied to each account's scope, after its own, e.g. for test fakes + final List overrides = const [], super.key, }) extends HookWidget { @override @@ -34,6 +38,7 @@ class const AccountScope({ NotificationRequestController.provider.overrideWithBuild( (_, _) => active.value.notificationRowId, ), + ...overrides, ], child: child, ); diff --git a/lib/widgets/avatars/avatar_or_hash.dart b/lib/widgets/avatars/avatar_or_hash.dart index bdaf657..bb14c78 100644 --- a/lib/widgets/avatars/avatar_or_hash.dart +++ b/lib/widgets/avatars/avatar_or_hash.dart @@ -12,9 +12,18 @@ final class const AvatarOrHash( }) extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final color = ColorHash(title).color; final box = ColoredBox( - color: ColorHash(title).color, - child: Center(child: Icon(Icons.person, size: dimension / 2)), + color: color, + child: Center( + child: Icon( + Icons.person, + size: dimension / 2, + color: ThemeData.estimateBrightnessForColor(color) == .dark + ? Colors.white + : Colors.black, + ), + ), ); return SizedBox( diff --git a/lib/widgets/users/user_span.dart b/lib/widgets/users/user_span.dart index c131a82..60cab86 100644 --- a/lib/widgets/users/user_span.dart +++ b/lib/widgets/users/user_span.dart @@ -10,28 +10,32 @@ import "package:nexus/models/event.dart"; /// A user's [name] in text, opening their popover when tapped. Unlike a widget /// in text, it wraps and ellipsizes with the rest of it. -class UserSpan extends TextSpan { - UserSpan( - BuildContext context, - String name, { - required String userId, - required TapGestureRecognizer recognizer, - String? roomId, - super.style, - super.children, - }) : super( - text: name, - recognizer: recognizer - ..onTapUp = (details) { - if (!context.mounted) return; - context.showUserPopover( - userId, - roomId: roomId, - anchor: context.globalRectAt(details.globalPosition), - ); - }, - ); +// Can't be const, since it sets the recognizer's onTapUp +// ignore: prefer_const_constructors_in_immutables +class UserSpan( + BuildContext context, + String name, { + required String userId, + required TapGestureRecognizer recognizer, + String? roomId, + super.style, + super.children, +}) extends TextSpan { + this + : super( + text: name, + recognizer: recognizer + ..onTapUp = (details) { + if (!context.mounted) return; + + context.showUserPopover( + userId, + roomId: roomId, + anchor: context.globalRectAt(details.globalPosition), + ); + }, + ); factory UserSpan.sender( BuildContext context, diff --git a/linux/nexus.federated.nexus.metainfo.xml b/linux/nexus.federated.nexus.metainfo.xml new file mode 100644 index 0000000..36506c4 --- /dev/null +++ b/linux/nexus.federated.nexus.metainfo.xml @@ -0,0 +1,75 @@ + + + nexus.federated.nexus + CC0-1.0 + GPL-3.0-only + + Nexus + A simple and user-friendly Matrix client + + QuadRadical + + + +

+ Nexus is a simple and user-friendly client for Matrix, the open and + decentralized chat network. It's made with Flutter, with gomuks handling + the Matrix side. +

+

Features include:

+
    +
  • Spaces, threads, replies and reactions
  • +
  • End-to-end encryption, with verification and recovery keys
  • +
  • Polls, location sharing, and custom emoji and sticker packs
  • +
  • Multiple accounts
  • +
  • Notifications through UnifiedPush
  • +
  • Layouts for phones and desktops, including Linux phones
  • +
+
+ + nexus.federated.nexus.desktop + + https://git.federated.nexus/Nexus/nexus + https://git.federated.nexus/Nexus/nexus/issues + https://git.federated.nexus/Nexus/nexus + + + + + Chatting in a room, with its members + https://git.federated.nexus/Nexus/nexus/raw/branch/main/assets/screenshots/desktop-light.png + + + The same, in dark mode + https://git.federated.nexus/Nexus/nexus/raw/branch/main/assets/screenshots/desktop-dark.png + + + Chatting on a phone + https://git.federated.nexus/Nexus/nexus/raw/branch/main/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png + + + Spaces and rooms on a phone + https://git.federated.nexus/Nexus/nexus/raw/branch/main/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png + + + + + 360 + + + keyboard + pointing + touch + + + + intense + intense + + + + + + + +
diff --git a/linux/nix/default.nix b/linux/nix/default.nix index 4fc6983..da726d2 100644 --- a/linux/nix/default.nix +++ b/linux/nix/default.nix @@ -71,6 +71,39 @@ runtimeEnv = buildEnv.env; text = builtins.readFile ./e2e.sh; }; + screenshots = pkgs.writeShellApplication { + name = "nexus-screenshots"; + runtimeInputs = + buildEnv.packages + ++ (with pkgs; [ + dbus + oxipng + pngquant + ]); + runtimeEnv = buildEnv.env; + text = builtins.readFile ./screenshots.sh; + }; + + headless = + program: + lib.getExe ( + pkgs.writeShellApplication { + name = "${lib.getName program}-headless"; + runtimeInputs = [ pkgs.cage ]; + runtimeEnv = { + WLR_BACKENDS = "headless"; + __EGL_VENDOR_LIBRARY_FILENAMES = "${pkgs.mesa}/share/glvnd/egl_vendor.d/50_mesa.json"; + }; + text = '' + unset DISPLAY WAYLAND_DISPLAY + # Wayland needs one, which CI may not have + XDG_RUNTIME_DIR="''${XDG_RUNTIME_DIR:-$(mktemp -d)}" + export XDG_RUNTIME_DIR + + exec cage -- ${lib.getExe program} "$@" + ''; + } + ); in { e2e = { @@ -98,24 +131,12 @@ e2e-headless = { type = "app"; - program = lib.getExe ( - pkgs.writeShellApplication { - name = "nexus-e2e-headless"; - runtimeInputs = [ pkgs.cage ]; - runtimeEnv = { - WLR_BACKENDS = "headless"; - __EGL_VENDOR_LIBRARY_FILENAMES = "${pkgs.mesa}/share/glvnd/egl_vendor.d/50_mesa.json"; - }; - text = '' - unset DISPLAY WAYLAND_DISPLAY - # Wayland needs one, which CI may not have - XDG_RUNTIME_DIR="''${XDG_RUNTIME_DIR:-$(mktemp -d)}" - export XDG_RUNTIME_DIR + program = headless e2e; + }; - exec cage -- ${lib.getExe e2e} "$@" - ''; - } - ); + screenshots = { + type = "app"; + program = headless screenshots; }; }; }; diff --git a/linux/nix/pkg/default.nix b/linux/nix/pkg/default.nix index fa9b8ac..8a74f71 100644 --- a/linux/nix/pkg/default.nix +++ b/linux/nix/pkg/default.nix @@ -70,6 +70,7 @@ flutter.buildFlutterApplication { postInstall = '' install -D assets/bundled/icon.svg $out/share/icons/hicolor/scalable/apps/nexus.svg install -Dm755 linux/*.desktop -t $out/share/applications + install -Dm644 linux/*.metainfo.xml -t $out/share/metainfo install -Dm644 linux/*.service -t $out/share/dbus-1/services # better_player loads libmpv at runtime to play videos, and audioplayers # needs GStreamer's plugins to decode audio @@ -93,6 +94,7 @@ flutter.buildFlutterApplication { meta = { description = "A simple and user-friendly Matrix client"; + license = lib.licenses.gpl3Only; mainProgram = "nexus"; platforms = lib.platforms.unix; diff --git a/linux/nix/screenshots.sh b/linux/nix/screenshots.sh new file mode 100644 index 0000000..cdf18ff --- /dev/null +++ b/linux/nix/screenshots.sh @@ -0,0 +1,30 @@ +# shellcheck shell=bash + +# Regenerates the README, AppStream and Fastlane screenshots, from +# integration_test/screenshots. Run it from the repo root. + +work="$(mktemp -d)" +# shellcheck disable=SC2329 # Run by the trap +cleanup() { rm -rf "$work"; } +trap cleanup EXIT + +GOCACHE="$(go env GOCACHE)" +export GOCACHE + +XDG_DATA_HOME="$work/data" XDG_CACHE_HOME="$work/cache" \ + dbus-run-session -- \ + flutter test integration_test/screenshots/screenshots_test.dart -d linux \ + --dart-define="SCREENSHOTS_DIR=$work/out" "$@" + +for shot in "$work"/out/*.png; do + pngquant --quality 65-95 --speed 1 --strip --force --ext .png "$shot" + oxipng --quiet --opt 4 --strip safe "$shot" +done + +install -Dm644 "$work"/out/desktop-*.png -t assets/screenshots + +phone=fastlane/metadata/android/en-US/images/phoneScreenshots +install -Dm644 "$work/out/phone-chat-light.png" "$phone/1.png" +install -Dm644 "$work/out/phone-chat-dark.png" "$phone/2.png" +install -Dm644 "$work/out/phone-rooms-light.png" "$phone/3.png" +install -Dm644 "$work/out/phone-rooms-dark.png" "$phone/4.png" diff --git a/pubspec.lock b/pubspec.lock index 537de65..9785051 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1460,7 +1460,7 @@ packages: source: hosted version: "2.4.1" shared_preferences_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: shared_preferences_platform_interface sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" diff --git a/pubspec.yaml b/pubspec.yaml index d58c33e..be53944 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -128,6 +128,7 @@ dev_dependencies: sdk: flutter app_links_platform_interface: 2.0.4 url_launcher_platform_interface: 2.3.2 + shared_preferences_platform_interface: 2.4.2 build_runner: 2.17.1 flutter_lints: 6.0.0 freezed: 4.0.3