From 7e8c0673904a19b2f36a57ac0f796a74d7c4de7b Mon Sep 17 00:00:00 2001 From: bemain Date: Thu, 12 Mar 2026 15:12:56 +0100 Subject: [PATCH 1/9] feat(database): setup supabase --- lib/database/database.dart | 55 +++++++ lib/database/model.dart | 27 +++ pubspec.lock | 328 ++++++++++++++++++++++++++++++++++++- pubspec.yaml | 7 +- 4 files changed, 412 insertions(+), 5 deletions(-) create mode 100644 lib/database/database.dart create mode 100644 lib/database/model.dart diff --git a/lib/database/database.dart b/lib/database/database.dart new file mode 100644 index 0000000..9bd6a45 --- /dev/null +++ b/lib/database/database.dart @@ -0,0 +1,55 @@ +import 'package:musbx/database/announcement.dart'; +import 'package:musbx/database/model.dart'; +import 'package:musbx/keys.dart'; +import 'package:musbx/utils/utils.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class Database { + Database._(); + + /// The supabase client used internally. + static final SupabaseClient client = Supabase.instance.client; + + /// Whether the database has been [initialize]d. + static bool isInitialized = false; + + /// Initialize the database connection. + static Future initialize() async { + if (isInitialized) return; + isInitialized = true; + + await Supabase.initialize( + url: supabaseUrl, + anonKey: supabaseAnonKey, + ); + } + + /// The reference to the 'announcements' table. + static final DatabaseService announcements = + DatabaseService( + "announcements", + fromJson: Announcement.fromJson, + ); +} + +class DatabaseService { + DatabaseService( + String table, { + required this.fromJson, + }) : table = Database.client.from(table); + + final SupabaseQueryBuilder table; + + final T Function(Json json) fromJson; + + /// Perform an INSERT into the [table]. + PostgrestFilterBuilder insert(T object) { + return table.insert(object.toJson()); + } + + PostgrestBuilder, List, List> select() { + return table.select().withConverter( + (data) => data.map(fromJson).toList(), + ); + } +} diff --git a/lib/database/model.dart b/lib/database/model.dart new file mode 100644 index 0000000..29ec5b7 --- /dev/null +++ b/lib/database/model.dart @@ -0,0 +1,27 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:musbx/utils/utils.dart'; +import 'package:uuid/uuid.dart'; + +/// A model with an [id] property that can be serialized to json and stored +/// on the database. +abstract class Model { + Model({ + String? id, + DateTime? createdAt, + }) : id = id ?? Uuid().v4(), + createdAt = createdAt ?? DateTime.now(); + + /// The unique uuid of this object. + @JsonKey(required: true) + final String id; + + /// When this object was created. + @JsonKey(required: true) + final DateTime createdAt; + + /// Serialize this object as json. + Json toJson(); + + @override + String toString() => "Model($id)"; +} diff --git a/pubspec.lock b/pubspec.lock index 729e5d1..7b962d5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" + url: "https://pub.dev" + source: hosted + version: "93.0.0" _flutterfire_internals: dependency: transitive description: @@ -9,6 +17,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.67" + adaptive_number: + dependency: transitive + description: + name: adaptive_number + sha256: "3a567544e9b5c9c803006f51140ad544aedc79604fd4f3f2c1380003f97c1d77" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b + url: "https://pub.dev" + source: hosted + version: "10.0.1" ansicolor: dependency: transitive description: @@ -21,10 +45,10 @@ packages: dependency: "direct main" description: name: app_links - sha256: "3462d9defc61565fde4944858b59bec5be2b9d5b05f20aed190adb3ad08a7abc" + sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8" url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "6.4.1" app_links_linux: dependency: transitive description: @@ -129,6 +153,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: "275bf6bb2a00a9852c28d4e0b410da1d833a734d57d39d44f94bfc895a484ec3" + url: "https://pub.dev" + source: hosted + version: "4.0.4" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + url: "https://pub.dev" + source: hosted + version: "4.1.1" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "7981eb922842c77033026eb4341d5af651562008cdb116bdfa31fc46516b6462" + url: "https://pub.dev" + source: hosted + version: "2.12.2" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "6ae8a6435a8c6520c7077b107e77f1fb4ba7009633259a4d49a8afd8e7efc5e9" + url: "https://pub.dev" + source: hosted + version: "8.12.4" characters: dependency: transitive description: @@ -169,6 +241,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + url: "https://pub.dev" + source: hosted + version: "4.11.1" collection: dependency: transitive description: @@ -193,6 +273,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" cross_file: dependency: transitive description: @@ -217,6 +305,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + dart_jsonwebtoken: + dependency: transitive + description: + name: dart_jsonwebtoken + sha256: c6ecb3bb991c459b91c5adf9e871113dcb32bbe8fe7ca2c92723f88ffc1e0b7a + url: "https://pub.dev" + source: hosted + version: "3.3.2" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2" + url: "https://pub.dev" + source: hosted + version: "3.1.7" dbus: dependency: transitive description: @@ -266,6 +370,14 @@ packages: url: "https://github.com/hasali19/material-foundation-flutter-packages.git" source: git version: "1.7.0" + ed25519_edwards: + dependency: transitive + description: + name: ed25519_edwards + sha256: "6ce0112d131327ec6d42beede1e5dfd526069b18ad45dcf654f15074ad9276cd" + url: "https://pub.dev" + source: hosted + version: "0.3.1" fake_async: dependency: transitive description: @@ -434,6 +546,14 @@ packages: description: flutter source: sdk version: "0.0.0" + functions_client: + dependency: transitive + description: + name: functions_client + sha256: "94074d62167ae634127ef6095f536835063a7dc80f2b1aa306d2346ff9023996" + url: "https://pub.dev" + source: hosted + version: "2.5.0" gauges: dependency: "direct main" description: @@ -474,6 +594,22 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.0" + gotrue: + dependency: transitive + description: + name: gotrue + sha256: f7b52008311941a7c3e99f9590c4ee32dfc102a5442e43abf1b287d9f8cc39b2 + url: "https://pub.dev" + source: hosted + version: "2.18.0" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" gtk: dependency: transitive description: @@ -514,6 +650,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" http_parser: dependency: "direct main" description: @@ -570,6 +714,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.20.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" js: dependency: transitive description: @@ -579,13 +731,21 @@ packages: source: hosted version: "0.7.2" json_annotation: - dependency: transitive + dependency: "direct main" description: name: json_annotation sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 url: "https://pub.dev" source: hosted version: "4.11.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: "44729f5c45748e6748f6b9a57ab8f7e4336edc8ae41fc295070e3814e616a6c0" + url: "https://pub.dev" + source: hosted + version: "6.13.0" just_waveform: dependency: "direct main" description: @@ -594,6 +754,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.0.7" + jwt_decode: + dependency: transitive + description: + name: jwt_decode + sha256: d2e9f68c052b2225130977429d30f187aa1981d789c76ad104a32243cfdebfbb + url: "https://pub.dev" + source: hosted + version: "0.3.1" leak_tracker: dependency: transitive description: @@ -705,6 +873,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.3.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" package_info_plus: dependency: "direct main" description: @@ -865,6 +1041,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" posix: dependency: transitive description: @@ -873,6 +1065,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.5.0" + postgrest: + dependency: transitive + description: + name: postgrest + sha256: f4b6bb24b465c47649243ef0140475de8a0ec311dc9c75ebe573b2dcabb10460 + url: "https://pub.dev" + source: hosted + version: "2.6.0" pub_semver: dependency: "direct main" description: @@ -881,6 +1081,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + realtime_client: + dependency: transitive + description: + name: realtime_client + sha256: "5268afc208d02fb9109854d262c1ebf6ece224cd285199ae1d2f92d2ff49dbf1" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + retry: + dependency: transitive + description: + name: retry + sha256: "822e118d5b3aafed083109c72d5f484c6dc66707885e07c0fbcb8b986bba7efc" + url: "https://pub.dev" + source: hosted + version: "3.1.2" rxdart: dependency: transitive description: @@ -945,6 +1169,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" simple_icons: dependency: "direct main" description: @@ -958,6 +1198,22 @@ packages: description: flutter source: sdk version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17" + url: "https://pub.dev" + source: hosted + version: "4.2.0" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "4a85e90b50694e652075cbe4575665539d253e6ec10e46e76b45368ab5e3caae" + url: "https://pub.dev" + source: hosted + version: "1.3.10" source_span: dependency: transitive description: @@ -1014,6 +1270,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.12.1" + storage_client: + dependency: transitive + description: + name: storage_client + sha256: "1c61b19ed9e78f37fdd1ca8b729ab8484e6c8fe82e15c87e070b861951183657" + url: "https://pub.dev" + source: hosted + version: "2.4.1" stream_channel: dependency: transitive description: @@ -1022,6 +1286,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: @@ -1030,6 +1302,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + supabase: + dependency: transitive + description: + name: supabase + sha256: cc039f63a3168386b3a4f338f3bff342c860d415a3578f3fbe854024aee6f911 + url: "https://pub.dev" + source: hosted + version: "2.10.2" + supabase_flutter: + dependency: "direct main" + description: + name: supabase_flutter + sha256: "92b2416ecb6a5c3ed34cf6e382b35ce6cc8921b64f2a9299d5d28968d42b09bb" + url: "https://pub.dev" + source: hosted + version: "2.12.0" synchronized: dependency: transitive description: @@ -1144,7 +1432,7 @@ packages: source: hosted version: "3.1.5" uuid: - dependency: transitive + dependency: "direct main" description: name: uuid sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" @@ -1175,6 +1463,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.2" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" web: dependency: transitive description: @@ -1183,6 +1479,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" webview_flutter: dependency: transitive description: @@ -1255,6 +1567,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.3" + yet_another_json_isolate: + dependency: transitive + description: + name: yet_another_json_isolate + sha256: fe45897501fa156ccefbfb9359c9462ce5dec092f05e8a56109db30be864f01e + url: "https://pub.dev" + source: hosted + version: "2.1.0" sdks: dart: ">=3.10.3 <4.0.0" flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index 66361be..5adc1ec 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -70,13 +70,16 @@ dependencies: ref: 9ebb16d7fa31c7d84e99cb0f262073ca3fed9749 material_plus: path: ../material_plus - app_links: ^7.0.0 uri_to_file: # The version on pub.dev is terribly outdated git: url: https://github.com/sumitsharansatsangi/uri-to-file ref: main crypto: ^3.0.7 flutter_m3shapes: ^1.0.0+2 + supabase_flutter: ^2.12.0 + app_links: ^6.4.1 + json_annotation: ^4.11.0 + uuid: ^4.5.3 @@ -93,6 +96,8 @@ dev_dependencies: # package. See that file for information about deactivating specific lint # rules and activating additional ones. flutter_lints: ^6.0.0 + build_runner: ^2.12.2 + json_serializable: ^6.13.0 # To apply changes to the icon configuration, run: # `dart run flutter_launcher_icons` From 69bbf105251db22a09250eb16fc27ef3dcc576d0 Mon Sep 17 00:00:00 2001 From: bemain Date: Thu, 12 Mar 2026 15:16:23 +0100 Subject: [PATCH 2/9] feat(database): create the `announcements` table --- lib/database/announcement.dart | 30 ++++++++++++++++++++++++++++++ lib/database/announcement.g.dart | 27 +++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 lib/database/announcement.dart create mode 100644 lib/database/announcement.g.dart diff --git a/lib/database/announcement.dart b/lib/database/announcement.dart new file mode 100644 index 0000000..344f015 --- /dev/null +++ b/lib/database/announcement.dart @@ -0,0 +1,30 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:musbx/database/model.dart'; +import 'package:musbx/utils/utils.dart'; + +part 'announcement.g.dart'; + +@JsonSerializable() +class Announcement extends Model { + /// An announcement shown to all users on startup. + Announcement({ + super.id, + super.createdAt, + required this.title, + required this.content, + }); + + /// The title of this announcement. + final String title; + + /// The content of this announcement. + final String? content; + + static Announcement fromJson(Json json) => _$AnnouncementFromJson(json); + + @override + Json toJson() => _$AnnouncementToJson(this); + + @override + String toString() => "Announcement($title)"; +} diff --git a/lib/database/announcement.g.dart b/lib/database/announcement.g.dart new file mode 100644 index 0000000..3636506 --- /dev/null +++ b/lib/database/announcement.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'announcement.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Announcement _$AnnouncementFromJson(Map json) { + $checkKeys(json, requiredKeys: const ['id', 'createdAt']); + return Announcement( + id: json['id'] as String?, + createdAt: json['createdAt'] == null + ? null + : DateTime.parse(json['createdAt'] as String), + title: json['title'] as String, + content: json['content'] as String?, + ); +} + +Map _$AnnouncementToJson(Announcement instance) => + { + 'id': instance.id, + 'createdAt': instance.createdAt.toIso8601String(), + 'title': instance.title, + 'content': instance.content, + }; From 04a49b9d15d40d7a02da0e732f9b59d68b8ef86e Mon Sep 17 00:00:00 2001 From: bemain Date: Thu, 12 Mar 2026 15:37:34 +0100 Subject: [PATCH 3/9] fix(database): reduce modularization of database tables While reducing repetitiveness it was too compicated to use and reengineered a lot of what supabase already provided --- lib/database/database.dart | 33 +++------------------------------ lib/main.dart | 2 ++ 2 files changed, 5 insertions(+), 30 deletions(-) diff --git a/lib/database/database.dart b/lib/database/database.dart index 9bd6a45..52d430f 100644 --- a/lib/database/database.dart +++ b/lib/database/database.dart @@ -1,7 +1,4 @@ -import 'package:musbx/database/announcement.dart'; -import 'package:musbx/database/model.dart'; import 'package:musbx/keys.dart'; -import 'package:musbx/utils/utils.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; class Database { @@ -25,31 +22,7 @@ class Database { } /// The reference to the 'announcements' table. - static final DatabaseService announcements = - DatabaseService( - "announcements", - fromJson: Announcement.fromJson, - ); -} - -class DatabaseService { - DatabaseService( - String table, { - required this.fromJson, - }) : table = Database.client.from(table); - - final SupabaseQueryBuilder table; - - final T Function(Json json) fromJson; - - /// Perform an INSERT into the [table]. - PostgrestFilterBuilder insert(T object) { - return table.insert(object.toJson()); - } - - PostgrestBuilder, List, List> select() { - return table.select().withConverter( - (data) => data.map(fromJson).toList(), - ); - } + static final SupabaseQueryBuilder announcements = Database.client.from( + "announcements", + ); } diff --git a/lib/main.dart b/lib/main.dart index db70778..c564d6f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:material_plus/material_plus.dart'; import 'package:musbx/analytics.dart'; +import 'package:musbx/database/database.dart'; import 'package:musbx/navigation.dart'; import 'package:musbx/songs/player/songs.dart'; import 'package:musbx/theme.dart'; @@ -21,6 +22,7 @@ Future main() async { await PersistentValue.initialize(); await Directories.initialize(); + await Database.initialize(); await Analytics.initialize(); await Purchases.intialize(); From bfbda8fa9069540ccc24c865b06f852046115afa Mon Sep 17 00:00:00 2001 From: bemain Date: Thu, 12 Mar 2026 16:02:41 +0100 Subject: [PATCH 4/9] feat(announcements): create the `Announcements` class For handling announcements --- lib/database/announcement.g.dart | 8 ++++---- lib/database/model.dart | 2 +- lib/utils/announcements.dart | 34 ++++++++++++++++++++++++++++++++ lib/utils/launch_handler.dart | 16 ++++++++++++++- 4 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 lib/utils/announcements.dart diff --git a/lib/database/announcement.g.dart b/lib/database/announcement.g.dart index 3636506..24a9a69 100644 --- a/lib/database/announcement.g.dart +++ b/lib/database/announcement.g.dart @@ -7,12 +7,12 @@ part of 'announcement.dart'; // ************************************************************************** Announcement _$AnnouncementFromJson(Map json) { - $checkKeys(json, requiredKeys: const ['id', 'createdAt']); + $checkKeys(json, requiredKeys: const ['id', 'created_at']); return Announcement( id: json['id'] as String?, - createdAt: json['createdAt'] == null + createdAt: json['created_at'] == null ? null - : DateTime.parse(json['createdAt'] as String), + : DateTime.parse(json['created_at'] as String), title: json['title'] as String, content: json['content'] as String?, ); @@ -21,7 +21,7 @@ Announcement _$AnnouncementFromJson(Map json) { Map _$AnnouncementToJson(Announcement instance) => { 'id': instance.id, - 'createdAt': instance.createdAt.toIso8601String(), + 'created_at': instance.createdAt.toIso8601String(), 'title': instance.title, 'content': instance.content, }; diff --git a/lib/database/model.dart b/lib/database/model.dart index 29ec5b7..b834ebe 100644 --- a/lib/database/model.dart +++ b/lib/database/model.dart @@ -16,7 +16,7 @@ abstract class Model { final String id; /// When this object was created. - @JsonKey(required: true) + @JsonKey(required: true, name: "created_at") final DateTime createdAt; /// Serialize this object as json. diff --git a/lib/utils/announcements.dart b/lib/utils/announcements.dart new file mode 100644 index 0000000..64bf447 --- /dev/null +++ b/lib/utils/announcements.dart @@ -0,0 +1,34 @@ +import 'package:musbx/database/announcement.dart'; +import 'package:musbx/database/database.dart'; +import 'package:musbx/utils/launch_handler.dart'; + +class Announcements { + Announcements._(); + + /// Get the latest announcement from the database. + static Future getLatest() async { + return await Database.announcements + .select() + .order('created_at') + .limit(1) + .single() + .withConverter(Announcement.fromJson); + } + + /// Get all announcements from the database. + static Future> getAll() async { + return await Database.announcements.select().withConverter( + (data) => data.map(Announcement.fromJson).toList(), + ); + } + + /// Get all announcements from the database that have not been seen before. + static Future> getUnread() async { + return await Database.announcements + .select() + .gt("created_at", LaunchHandler.previousLaunchAt.toIso8601String()) + .withConverter( + (data) => data.map(Announcement.fromJson).toList(), + ); + } +} diff --git a/lib/utils/launch_handler.dart b/lib/utils/launch_handler.dart index e4a5280..72b1c67 100644 --- a/lib/utils/launch_handler.dart +++ b/lib/utils/launch_handler.dart @@ -44,8 +44,22 @@ class LaunchHandler { initialValue: "0", ); + /// When the app was last launched. + static final DateTime previousLaunchAt = launchAt.value; + + /// When the app was launched. + static final TransformedPersistentValue launchAt = + TransformedPersistentValue( + "lastLaunchAt", + initialValue: DateTime.utc(2000), // Some time really long ago. + from: (value) => DateTime.parse(value), + to: (value) => value.toIso8601String(), + ); + /// Called whenever the app launches. - static Future onLaunch() async {} + static Future onLaunch() async { + launchAt.value = DateTime.now(); + } /// Called when the app is launched for the first time with a new version. static Future onFirstLaunchWithVersion() async { From 8f7854317b3ab39b0a9ada9239f3204d2fd22e3e Mon Sep 17 00:00:00 2001 From: bemain Date: Fri, 13 Mar 2026 14:56:10 +0100 Subject: [PATCH 5/9] feat(announcements): create the announcements page and app bar icon --- lib/navigation.dart | 6 ++ lib/songs/library_page/library_page.dart | 2 + lib/widgets/announcements_page.dart | 95 ++++++++++++++++++++++++ lib/widgets/default_app_bar.dart | 2 + 4 files changed, 105 insertions(+) create mode 100644 lib/widgets/announcements_page.dart diff --git a/lib/navigation.dart b/lib/navigation.dart index 1c9bd00..b04a907 100644 --- a/lib/navigation.dart +++ b/lib/navigation.dart @@ -15,6 +15,7 @@ import 'package:musbx/songs/song_page/song_page.dart'; import 'package:musbx/tuner/tuner_page.dart'; import 'package:musbx/utils/launch_handler.dart'; import 'package:musbx/utils/purchases.dart'; +import 'package:musbx/widgets/announcements_page.dart'; import 'package:musbx/widgets/custom_icons.dart'; import 'package:musbx/widgets/exception_dialogs.dart'; @@ -33,6 +34,7 @@ class Routes { static const String licenses = "/settings/licenses"; static const String contact = "/settings/contact"; + static const String announcements = "/announcements"; /// The top-level shell branches. static const List branches = [metronome, library, tuner, drone]; @@ -114,6 +116,10 @@ class Navigation { ), ], ), + GoRoute( + path: Routes.announcements, + builder: (context, state) => AnnouncementsPage(), + ), StatefulShellRoute( builder: _buildShell, diff --git a/lib/songs/library_page/library_page.dart b/lib/songs/library_page/library_page.dart index b000d9d..8bb510d 100644 --- a/lib/songs/library_page/library_page.dart +++ b/lib/songs/library_page/library_page.dart @@ -8,6 +8,7 @@ import 'package:musbx/songs/library_page/upload_file_button.dart'; import 'package:musbx/songs/player/library.dart'; import 'package:musbx/songs/player/song.dart'; import 'package:musbx/songs/player/songs.dart'; +import 'package:musbx/widgets/announcements_page.dart'; import 'package:musbx/widgets/default_app_bar.dart'; import 'package:musbx/widgets/exception_dialogs.dart'; @@ -27,6 +28,7 @@ class LibraryPage extends StatelessWidget { expandedHeight: 128, title: LibrarySearchBar(), actions: const [ + AnnouncementsButton(), GetPremiumButton(), SettingsButton(), ], diff --git a/lib/widgets/announcements_page.dart b/lib/widgets/announcements_page.dart new file mode 100644 index 0000000..0f0a2e8 --- /dev/null +++ b/lib/widgets/announcements_page.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:material_plus/material_plus.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:musbx/database/announcement.dart'; +import 'package:musbx/navigation.dart'; +import 'package:musbx/utils/announcements.dart'; + +class AnnouncementsPage extends StatelessWidget { + AnnouncementsPage({super.key}); + + final Future> future = Announcements.getAll(); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text("Announcements"), + ), + body: Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: FutureBuilder( + future: future, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center( + child: Text("Error: ${snapshot.error}"), + ); + } + + if (!snapshot.hasData) { + return Center( + child: CircularProgressIndicator(), + ); + } + + return SegmentedCard( + children: [ + for (Announcement announcement in snapshot.data!) + AnnouncementTile(announcement: announcement), + ], + ); + }, + ), + ), + ); + } +} + +class AnnouncementTile extends StatelessWidget { + const AnnouncementTile({super.key, required this.announcement}); + + final Announcement announcement; + + @override + Widget build(BuildContext context) { + return ListTile( + contentPadding: EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + title: Text(announcement.title), + subtitle: Text(announcement.content ?? ""), + ); + } +} + +class AnnouncementsButton extends StatelessWidget { + /// A simple icon button that opens the "Announcements"-page when pressed + /// and displays the number of unread announcements. + const AnnouncementsButton({super.key}); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: Announcements.getUnread(), + builder: (context, snapshot) { + final List unread = snapshot.data ?? []; + + return IconButton( + onPressed: () { + context.push(Routes.announcements); + }, + icon: Badge( + backgroundColor: Theme.of(context).colorScheme.primary, + textColor: Theme.of(context).colorScheme.onPrimary, + isLabelVisible: unread.isNotEmpty, + label: Text(unread.length.toString()), + child: Icon(Symbols.notifications), + ), + ); + }, + ); + } +} diff --git a/lib/widgets/default_app_bar.dart b/lib/widgets/default_app_bar.dart index ba8a151..798704b 100644 --- a/lib/widgets/default_app_bar.dart +++ b/lib/widgets/default_app_bar.dart @@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:musbx/navigation.dart'; import 'package:musbx/utils/purchases.dart'; +import 'package:musbx/widgets/announcements_page.dart'; import 'package:musbx/widgets/exception_dialogs.dart'; class DefaultAppBar extends StatelessWidget implements PreferredSizeWidget { @@ -29,6 +30,7 @@ class DefaultAppBar extends StatelessWidget implements PreferredSizeWidget { leading: leading, title: title, actions: const [ + AnnouncementsButton(), GetPremiumButton(), SettingsButton(), ], From 59864ee8fd4b97613273bb3355f1ca7f67098309 Mon Sep 17 00:00:00 2001 From: bemain Date: Fri, 13 Mar 2026 15:38:48 +0100 Subject: [PATCH 6/9] feat(announcements): render `content` as markdown --- lib/main.dart | 4 + lib/utils/announcements.dart | 23 +++- lib/utils/launch_handler.dart | 16 +-- lib/widgets/announcements_page.dart | 170 ++++++++++++++++++++++------ pubspec.lock | 16 +++ pubspec.yaml | 1 + 6 files changed, 173 insertions(+), 57 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index c564d6f..83462e6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -25,6 +25,10 @@ Future main() async { await Database.initialize(); await Analytics.initialize(); await Purchases.intialize(); + await PersistentValue.preferences.setString( + "announcements/readAt", + DateTime.utc(200).toIso8601String(), + ); await Songs.initialize(); await Notifications.initialize(); diff --git a/lib/utils/announcements.dart b/lib/utils/announcements.dart index 64bf447..4f43047 100644 --- a/lib/utils/announcements.dart +++ b/lib/utils/announcements.dart @@ -1,10 +1,19 @@ +import 'package:material_plus/material_plus.dart'; import 'package:musbx/database/announcement.dart'; import 'package:musbx/database/database.dart'; -import 'package:musbx/utils/launch_handler.dart'; class Announcements { Announcements._(); + /// The last time the announcements were read. + static final TransformedPersistentValue readAt = + TransformedPersistentValue( + "announcements/readAt", + initialValue: DateTime.now(), + from: (value) => DateTime.parse(value), + to: (value) => value.toIso8601String(), + ); + /// Get the latest announcement from the database. static Future getLatest() async { return await Database.announcements @@ -17,16 +26,20 @@ class Announcements { /// Get all announcements from the database. static Future> getAll() async { - return await Database.announcements.select().withConverter( - (data) => data.map(Announcement.fromJson).toList(), - ); + return await Database.announcements + .select() + .order('created_at') + .withConverter( + (data) => data.map(Announcement.fromJson).toList(), + ); } /// Get all announcements from the database that have not been seen before. static Future> getUnread() async { return await Database.announcements .select() - .gt("created_at", LaunchHandler.previousLaunchAt.toIso8601String()) + .gt("created_at", readAt.value.toIso8601String()) + .order('created_at') .withConverter( (data) => data.map(Announcement.fromJson).toList(), ); diff --git a/lib/utils/launch_handler.dart b/lib/utils/launch_handler.dart index 72b1c67..e4a5280 100644 --- a/lib/utils/launch_handler.dart +++ b/lib/utils/launch_handler.dart @@ -44,22 +44,8 @@ class LaunchHandler { initialValue: "0", ); - /// When the app was last launched. - static final DateTime previousLaunchAt = launchAt.value; - - /// When the app was launched. - static final TransformedPersistentValue launchAt = - TransformedPersistentValue( - "lastLaunchAt", - initialValue: DateTime.utc(2000), // Some time really long ago. - from: (value) => DateTime.parse(value), - to: (value) => value.toIso8601String(), - ); - /// Called whenever the app launches. - static Future onLaunch() async { - launchAt.value = DateTime.now(); - } + static Future onLaunch() async {} /// Called when the app is launched for the first time with a new version. static Future onFirstLaunchWithVersion() async { diff --git a/lib/widgets/announcements_page.dart b/lib/widgets/announcements_page.dart index 0f0a2e8..f8be517 100644 --- a/lib/widgets/announcements_page.dart +++ b/lib/widgets/announcements_page.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:go_router/go_router.dart'; import 'package:material_plus/material_plus.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -7,12 +9,15 @@ import 'package:musbx/navigation.dart'; import 'package:musbx/utils/announcements.dart'; class AnnouncementsPage extends StatelessWidget { - AnnouncementsPage({super.key}); - - final Future> future = Announcements.getAll(); + const AnnouncementsPage({super.key}); @override Widget build(BuildContext context) { + // Mark all announcements as read + SchedulerBinding.instance.addPostFrameCallback((_) async { + Announcements.readAt.value = DateTime.now(); + }); + return Scaffold( appBar: AppBar( title: Text("Announcements"), @@ -20,7 +25,7 @@ class AnnouncementsPage extends StatelessWidget { body: Padding( padding: EdgeInsets.symmetric(horizontal: 8), child: FutureBuilder( - future: future, + future: Announcements.getAll(), builder: (context, snapshot) { if (snapshot.hasError) { return Center( @@ -28,15 +33,10 @@ class AnnouncementsPage extends StatelessWidget { ); } - if (!snapshot.hasData) { - return Center( - child: CircularProgressIndicator(), - ); - } - - return SegmentedCard( + return ListView( children: [ - for (Announcement announcement in snapshot.data!) + for (Announcement? announcement + in snapshot.data ?? [null, null, null]) AnnouncementTile(announcement: announcement), ], ); @@ -48,19 +48,112 @@ class AnnouncementsPage extends StatelessWidget { } class AnnouncementTile extends StatelessWidget { + static const List months = [ + "jan", + "feb", + "mar", + "apr", + "may", + "jun", + "jul", + "aug", + "sep", + "oct", + "nov", + "dec", + ]; + const AnnouncementTile({super.key, required this.announcement}); - final Announcement announcement; + final Announcement? announcement; @override Widget build(BuildContext context) { - return ListTile( - contentPadding: EdgeInsets.symmetric( - horizontal: 24, - vertical: 12, + final ThemeData theme = Theme.of(context); + + String formatDate(DateTime d) => + "${d.day} ${months[d.month - 1].toUpperCase()}${d.year != DateTime.now().year ? " ${d.year}" : ""}, ${d.hour}:${d.minute}"; + + if (this.announcement == null) return _buildPlaceholder(context); + final Announcement announcement = this.announcement!; + + return Card( + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + Row( + children: [ + Text( + announcement.title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + Text( + formatDate(announcement.createdAt.toLocal()), + style: theme.textTheme.labelMedium, + ), + MarkdownBody( + data: announcement.content ?? "", + softLineBreak: true, + styleSheet: MarkdownStyleSheet.fromTheme(theme).copyWith( + p: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + blockquoteDecoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: theme.colorScheme.primary, + ), + blockquote: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onPrimary, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildPlaceholder(BuildContext context) { + final ThemeData theme = Theme.of(context); + + return Card( + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + // Title + TextPlaceholder( + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + // Date + TextPlaceholder( + width: 100, + style: theme.textTheme.labelMedium, + ), + // Content + TextPlaceholder(style: theme.textTheme.bodyMedium), + TextPlaceholder(style: theme.textTheme.bodyMedium), + TextPlaceholder(width: 200, style: theme.textTheme.bodyMedium), + ], + ), ), - title: Text(announcement.title), - subtitle: Text(announcement.content ?? ""), ); } } @@ -72,24 +165,27 @@ class AnnouncementsButton extends StatelessWidget { @override Widget build(BuildContext context) { - return FutureBuilder( - future: Announcements.getUnread(), - builder: (context, snapshot) { - final List unread = snapshot.data ?? []; - - return IconButton( - onPressed: () { - context.push(Routes.announcements); - }, - icon: Badge( - backgroundColor: Theme.of(context).colorScheme.primary, - textColor: Theme.of(context).colorScheme.onPrimary, - isLabelVisible: unread.isNotEmpty, - label: Text(unread.length.toString()), - child: Icon(Symbols.notifications), - ), - ); - }, + return ValueListenableBuilder( + valueListenable: Announcements.readAt, + builder: (context, value, child) => FutureBuilder( + future: Announcements.getUnread(), + builder: (context, snapshot) { + final List unread = snapshot.data ?? []; + + return IconButton( + onPressed: () { + context.push(Routes.announcements); + }, + icon: Badge( + backgroundColor: Theme.of(context).colorScheme.primary, + textColor: Theme.of(context).colorScheme.onPrimary, + isLabelVisible: unread.isNotEmpty, + label: Text(unread.length.toString()), + child: Icon(Symbols.notifications), + ), + ); + }, + ), ); } } diff --git a/pubspec.lock b/pubspec.lock index 7b962d5..838d0e3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -503,6 +503,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0+2" + flutter_markdown_plus: + dependency: "direct main" + description: + name: flutter_markdown_plus + sha256: "039177906850278e8fb1cd364115ee0a46281135932fa8ecea8455522166d2de" + url: "https://pub.dev" + source: hosted + version: "1.0.7" flutter_native_splash: dependency: "direct dev" description: @@ -802,6 +810,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + markdown: + dependency: transitive + description: + name: markdown + sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" + url: "https://pub.dev" + source: hosted + version: "7.3.0" matcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 5adc1ec..bfefb68 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -80,6 +80,7 @@ dependencies: app_links: ^6.4.1 json_annotation: ^4.11.0 uuid: ^4.5.3 + flutter_markdown_plus: ^1.0.7 From c1a5e4e1f2e8bdb614cff4e88c804d77f75db2a7 Mon Sep 17 00:00:00 2001 From: bemain Date: Fri, 13 Mar 2026 15:51:04 +0100 Subject: [PATCH 7/9] feat(announcements): show latest announcement title as tooltip on startup --- lib/main.dart | 4 --- lib/songs/library_page/library_page.dart | 2 +- lib/widgets/announcements_page.dart | 37 +++++++++++++++++------- lib/widgets/default_app_bar.dart | 2 +- 4 files changed, 28 insertions(+), 17 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 83462e6..c564d6f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -25,10 +25,6 @@ Future main() async { await Database.initialize(); await Analytics.initialize(); await Purchases.intialize(); - await PersistentValue.preferences.setString( - "announcements/readAt", - DateTime.utc(200).toIso8601String(), - ); await Songs.initialize(); await Notifications.initialize(); diff --git a/lib/songs/library_page/library_page.dart b/lib/songs/library_page/library_page.dart index 8bb510d..d37ed9a 100644 --- a/lib/songs/library_page/library_page.dart +++ b/lib/songs/library_page/library_page.dart @@ -27,7 +27,7 @@ class LibraryPage extends StatelessWidget { toolbarHeight: 68, expandedHeight: 128, title: LibrarySearchBar(), - actions: const [ + actions: [ AnnouncementsButton(), GetPremiumButton(), SettingsButton(), diff --git a/lib/widgets/announcements_page.dart b/lib/widgets/announcements_page.dart index f8be517..daffb2e 100644 --- a/lib/widgets/announcements_page.dart +++ b/lib/widgets/announcements_page.dart @@ -161,7 +161,9 @@ class AnnouncementTile extends StatelessWidget { class AnnouncementsButton extends StatelessWidget { /// A simple icon button that opens the "Announcements"-page when pressed /// and displays the number of unread announcements. - const AnnouncementsButton({super.key}); + AnnouncementsButton({super.key}); + + final GlobalKey tooltipkey = GlobalKey(); @override Widget build(BuildContext context) { @@ -172,16 +174,29 @@ class AnnouncementsButton extends StatelessWidget { builder: (context, snapshot) { final List unread = snapshot.data ?? []; - return IconButton( - onPressed: () { - context.push(Routes.announcements); - }, - icon: Badge( - backgroundColor: Theme.of(context).colorScheme.primary, - textColor: Theme.of(context).colorScheme.onPrimary, - isLabelVisible: unread.isNotEmpty, - label: Text(unread.length.toString()), - child: Icon(Symbols.notifications), + if (unread.isNotEmpty) { + // Open tooltop + SchedulerBinding.instance.addPostFrameCallback((_) { + tooltipkey.currentState?.ensureTooltipVisible(); + }); + } + + return Tooltip( + key: tooltipkey, + triggerMode: TooltipTriggerMode.manual, + message: unread.firstOrNull?.title ?? "Notifications", + showDuration: const Duration(seconds: 3), + child: IconButton( + onPressed: () { + context.push(Routes.announcements); + }, + icon: Badge( + backgroundColor: Theme.of(context).colorScheme.primary, + textColor: Theme.of(context).colorScheme.onPrimary, + isLabelVisible: unread.isNotEmpty, + label: Text(unread.length.toString()), + child: Icon(Symbols.notifications), + ), ), ); }, diff --git a/lib/widgets/default_app_bar.dart b/lib/widgets/default_app_bar.dart index 798704b..b084d0e 100644 --- a/lib/widgets/default_app_bar.dart +++ b/lib/widgets/default_app_bar.dart @@ -29,7 +29,7 @@ class DefaultAppBar extends StatelessWidget implements PreferredSizeWidget { return AppBar( leading: leading, title: title, - actions: const [ + actions: [ AnnouncementsButton(), GetPremiumButton(), SettingsButton(), From 153709feb8d5d401c7474b31ade4e821aa35de3c Mon Sep 17 00:00:00 2001 From: bemain Date: Fri, 13 Mar 2026 16:23:05 +0100 Subject: [PATCH 8/9] fix(announcements): minor fixes --- lib/database/announcement.dart | 2 +- lib/database/database.dart | 3 +- lib/widgets/announcements_page.dart | 60 +++++++++++++++++++++-------- 3 files changed, 48 insertions(+), 17 deletions(-) diff --git a/lib/database/announcement.dart b/lib/database/announcement.dart index 344f015..e034e31 100644 --- a/lib/database/announcement.dart +++ b/lib/database/announcement.dart @@ -11,7 +11,7 @@ class Announcement extends Model { super.id, super.createdAt, required this.title, - required this.content, + this.content, }); /// The title of this announcement. diff --git a/lib/database/database.dart b/lib/database/database.dart index 52d430f..4eca24b 100644 --- a/lib/database/database.dart +++ b/lib/database/database.dart @@ -13,12 +13,13 @@ class Database { /// Initialize the database connection. static Future initialize() async { if (isInitialized) return; - isInitialized = true; await Supabase.initialize( url: supabaseUrl, anonKey: supabaseAnonKey, ); + + isInitialized = true; } /// The reference to the 'announcements' table. diff --git a/lib/widgets/announcements_page.dart b/lib/widgets/announcements_page.dart index daffb2e..21a76be 100644 --- a/lib/widgets/announcements_page.dart +++ b/lib/widgets/announcements_page.dart @@ -9,10 +9,14 @@ import 'package:musbx/navigation.dart'; import 'package:musbx/utils/announcements.dart'; class AnnouncementsPage extends StatelessWidget { - const AnnouncementsPage({super.key}); + AnnouncementsPage({super.key}); + + final Future> _future = Announcements.getAll(); @override Widget build(BuildContext context) { + final DateTime previousReadAt = Announcements.readAt.value; + // Mark all announcements as read SchedulerBinding.instance.addPostFrameCallback((_) async { Announcements.readAt.value = DateTime.now(); @@ -25,7 +29,7 @@ class AnnouncementsPage extends StatelessWidget { body: Padding( padding: EdgeInsets.symmetric(horizontal: 8), child: FutureBuilder( - future: Announcements.getAll(), + future: _future, builder: (context, snapshot) { if (snapshot.hasError) { return Center( @@ -37,7 +41,14 @@ class AnnouncementsPage extends StatelessWidget { children: [ for (Announcement? announcement in snapshot.data ?? [null, null, null]) - AnnouncementTile(announcement: announcement), + AnnouncementTile( + announcement: announcement, + isUnread: + announcement?.createdAt.toLocal().isAfter( + previousReadAt, + ) ?? + false, + ), ], ); }, @@ -63,16 +74,21 @@ class AnnouncementTile extends StatelessWidget { "dec", ]; - const AnnouncementTile({super.key, required this.announcement}); + const AnnouncementTile({ + super.key, + required this.announcement, + this.isUnread = false, + }); final Announcement? announcement; + final bool isUnread; @override Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); String formatDate(DateTime d) => - "${d.day} ${months[d.month - 1].toUpperCase()}${d.year != DateTime.now().year ? " ${d.year}" : ""}, ${d.hour}:${d.minute}"; + "${d.day} ${months[d.month - 1].toUpperCase()}${d.year != DateTime.now().year ? " ${d.year}" : ""}, ${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}"; if (this.announcement == null) return _buildPlaceholder(context); final Announcement announcement = this.announcement!; @@ -88,6 +104,7 @@ class AnnouncementTile extends StatelessWidget { spacing: 4, children: [ Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( announcement.title, @@ -95,6 +112,11 @@ class AnnouncementTile extends StatelessWidget { fontWeight: FontWeight.bold, ), ), + if (isUnread) + Badge( + backgroundColor: Theme.of(context).colorScheme.primary, + textColor: Theme.of(context).colorScheme.onPrimary, + ), ], ), Text( @@ -163,38 +185,46 @@ class AnnouncementsButton extends StatelessWidget { /// and displays the number of unread announcements. AnnouncementsButton({super.key}); - final GlobalKey tooltipkey = GlobalKey(); + /// Whether the tooltip with the title of the latest announcement has been shown. + static bool hasShownTooltip = false; + + final GlobalKey _tooltipKey = GlobalKey(); @override Widget build(BuildContext context) { return ValueListenableBuilder( valueListenable: Announcements.readAt, - builder: (context, value, child) => FutureBuilder( + builder: (context, readAt, child) => FutureBuilder( future: Announcements.getUnread(), builder: (context, snapshot) { final List unread = snapshot.data ?? []; if (unread.isNotEmpty) { - // Open tooltop - SchedulerBinding.instance.addPostFrameCallback((_) { - tooltipkey.currentState?.ensureTooltipVisible(); - }); + if (!hasShownTooltip) { + hasShownTooltip = true; + + // Open tooltip + SchedulerBinding.instance.addPostFrameCallback((_) { + _tooltipKey.currentState?.ensureTooltipVisible(); + }); + } } return Tooltip( - key: tooltipkey, + key: _tooltipKey, triggerMode: TooltipTriggerMode.manual, - message: unread.firstOrNull?.title ?? "Notifications", + message: unread.firstOrNull?.title ?? "Announcements", showDuration: const Duration(seconds: 3), child: IconButton( onPressed: () { context.push(Routes.announcements); }, - icon: Badge( + icon: Badge.count( backgroundColor: Theme.of(context).colorScheme.primary, textColor: Theme.of(context).colorScheme.onPrimary, isLabelVisible: unread.isNotEmpty, - label: Text(unread.length.toString()), + count: unread.length, + maxCount: 9, child: Icon(Symbols.notifications), ), ), From 2ee5da4f6b596b72681f9cb983361128abb3c117 Mon Sep 17 00:00:00 2001 From: bemain Date: Fri, 13 Mar 2026 16:27:02 +0100 Subject: [PATCH 9/9] fic(announcements): cleanup error message --- lib/widgets/announcements_page.dart | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/widgets/announcements_page.dart b/lib/widgets/announcements_page.dart index 21a76be..cc284a2 100644 --- a/lib/widgets/announcements_page.dart +++ b/lib/widgets/announcements_page.dart @@ -32,8 +32,19 @@ class AnnouncementsPage extends StatelessWidget { future: _future, builder: (context, snapshot) { if (snapshot.hasError) { - return Center( - child: Text("Error: ${snapshot.error}"), + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Icon( + Symbols.error, + size: 96, + ), + Text( + "Failed to load announcements. Please try again later.", + textAlign: TextAlign.center, + ), + ], ); }