Repository: zulip/zulip-flutter
Pull request #1511: Add and handle live-updates to saved snippets data
Author: login: PIG208
Labels: name: integration review
description: Added by maintainers when PR may be ready for integration
Created: 2025-05-13T18:50:04Z | Merged: 2025-05-22T22:48:32Z

This prepares the data model and API bindings for #1391 towards #863.


Diff:
diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart
index 0479b0428f..62789333e1 100644
--- a/lib/api/model/events.dart
+++ b/lib/api/model/events.dart
@@ -37,6 +37,13 @@ sealed class Event {
           case 'update': return RealmUserUpdateEvent.fromJson(json);
           default: return UnexpectedEvent.fromJson(json);
         }
+      case 'saved_snippets':
+        switch (json['op'] as String) {
+          case 'add': return SavedSnippetsAddEvent.fromJson(json);
+          case 'update': return SavedSnippetsUpdateEvent.fromJson(json);
+          case 'remove': return SavedSnippetsRemoveEvent.fromJson(json);
+          default: return UnexpectedEvent.fromJson(json);
+        }
       case 'stream':
         switch (json['op'] as String) {
           case 'create': return ChannelCreateEvent.fromJson(json);
@@ -336,6 +343,68 @@ class RealmUserUpdateEvent extends RealmUserEvent {
   Map<String, dynamic> toJson() => _$RealmUserUpdateEventToJson(this);
 }
 
+/// A Zulip event of type `saved_snippets`: https://zulip.com/api/get-events#saved_snippets-add
+sealed class SavedSnippetsEvent extends Event {
+  @override
+  @JsonKey(includeToJson: true)
+  String get type => 'saved_snippets';
+
+  String get op;
+
+  SavedSnippetsEvent({required super.id});
+}
+
+/// A [SavedSnippetsEvent] with op `add`: https://zulip.com/api/get-events#saved_snippets-add
+@JsonSerializable(fieldRename: FieldRename.snake)
+class SavedSnippetsAddEvent extends SavedSnippetsEvent {
+  @override
+  String get op => 'add';
+
+  final SavedSnippet savedSnippet;
+
+  SavedSnippetsAddEvent({required super.id, required this.savedSnippet});
+
+  factory SavedSnippetsAddEvent.fromJson(Map<String, dynamic> json) =>
+    _$SavedSnippetsAddEventFromJson(json);
+
+  @override
+  Map<String, dynamic> toJson() => _$SavedSnippetsAddEventToJson(this);
+}
+
+/// A [SavedSnippetsEvent] with op `update`: https://zulip.com/api/get-events#saved_snippets-update
+@JsonSerializable(fieldRename: FieldRename.snake)
+class SavedSnippetsUpdateEvent extends SavedSnippetsEvent {
+  @override
+  String get op => 'update';
+
+  final SavedSnippet savedSnippet;
+
+  SavedSnippetsUpdateEvent({required super.id, required this.savedSnippet});
+
+  factory SavedSnippetsUpdateEvent.fromJson(Map<String, dynamic> json) =>
+    _$SavedSnippetsUpdateEventFromJson(json);
+
+  @override
+  Map<String, dynamic> toJson() => _$SavedSnippetsUpdateEventToJson(this);
+}
+
+/// A [SavedSnippetsEvent] with op `remove`: https://zulip.com/api/get-events#saved_snippets-remove
+@JsonSerializable(fieldRename: FieldRename.snake)
+class SavedSnippetsRemoveEvent extends SavedSnippetsEvent {
+  @override
+  String get op => 'remove';
+
+  final int savedSnippetId;
+
+  SavedSnippetsRemoveEvent({required super.id, required this.savedSnippetId});
+
+  factory SavedSnippetsRemoveEvent.fromJson(Map<String, dynamic> json) =>
+    _$SavedSnippetsRemoveEventFromJson(json);
+
+  @override
+  Map<String, dynamic> toJson() => _$SavedSnippetsRemoveEventToJson(this);
+}
+
 /// A Zulip event of type `stream`.
 ///
 /// The corresponding API docs are in several places for
diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart
index 35206d77b9..94fe288150 100644
--- a/lib/api/model/events.g.dart
+++ b/lib/api/model/events.g.dart
@@ -203,6 +203,55 @@ Json? _$JsonConverterToJson<Json, Value>(
   Json? Function(Value value) toJson,
 ) => value == null ? null : toJson(value);
 
+SavedSnippetsAddEvent _$SavedSnippetsAddEventFromJson(
+  Map<String, dynamic> json,
+) => SavedSnippetsAddEvent(
+  id: (json['id'] as num).toInt(),
+  savedSnippet: SavedSnippet.fromJson(
+    json['saved_snippet'] as Map<String, dynamic>,
+  ),
+);
+
+Map<String, dynamic> _$SavedSnippetsAddEventToJson(
+  SavedSnippetsAddEvent instance,
+) => <String, dynamic>{
+  'id': instance.id,
+  'type': instance.type,
+  'saved_snippet': instance.savedSnippet,
+};
+
+SavedSnippetsUpdateEvent _$SavedSnippetsUpdateEventFromJson(
+  Map<String, dynamic> json,
+) => SavedSnippetsUpdateEvent(
+  id: (json['id'] as num).toInt(),
+  savedSnippet: SavedSnippet.fromJson(
+    json['saved_snippet'] as Map<String, dynamic>,
+  ),
+);
+
+Map<String, dynamic> _$SavedSnippetsUpdateEventToJson(
+  SavedSnippetsUpdateEvent instance,
+) => <String, dynamic>{
+  'id': instance.id,
+  'type': instance.type,
+  'saved_snippet': instance.savedSnippet,
+};
+
+SavedSnippetsRemoveEvent _$SavedSnippetsRemoveEventFromJson(
+  Map<String, dynamic> json,
+) => SavedSnippetsRemoveEvent(
+  id: (json['id'] as num).toInt(),
+  savedSnippetId: (json['saved_snippet_id'] as num).toInt(),
+);
+
+Map<String, dynamic> _$SavedSnippetsRemoveEventToJson(
+  SavedSnippetsRemoveEvent instance,
+) => <String, dynamic>{
+  'id': instance.id,
+  'type': instance.type,
+  'saved_snippet_id': instance.savedSnippetId,
+};
+
 ChannelCreateEvent _$ChannelCreateEventFromJson(Map<String, dynamic> json) =>
     ChannelCreateEvent(
       id: (json['id'] as num).toInt(),
diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart
index 054230a256..f4cc2fe5fc 100644
--- a/lib/api/model/initial_snapshot.dart
+++ b/lib/api/model/initial_snapshot.dart
@@ -48,6 +48,8 @@ class InitialSnapshot {
 
   final List<RecentDmConversation> recentPrivateConversations;
 
+  final List<SavedSnippet>? savedSnippets; // TODO(server-10)
+
   final List<Subscription> subscriptions;
 
   final UnreadMessagesSnapshot unreadMsgs;
@@ -132,6 +134,7 @@ class InitialSnapshot {
     required this.serverTypingStartedWaitPeriodMilliseconds,
     required this.realmEmoji,
     required this.recentPrivateConversations,
+    required this.savedSnippets,
     required this.subscriptions,
     required this.unreadMsgs,
     required this.streams,
diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart
index 570d7c2bba..36afb0a39f 100644
--- a/lib/api/model/initial_snapshot.g.dart
+++ b/lib/api/model/initial_snapshot.g.dart
@@ -45,6 +45,10 @@ InitialSnapshot _$InitialSnapshotFromJson(
       (json['recent_private_conversations'] as List<dynamic>)
           .map((e) => RecentDmConversation.fromJson(e as Map<String, dynamic>))
           .toList(),
+  savedSnippets:
+      (json['saved_snippets'] as List<dynamic>?)
+          ?.map((e) => SavedSnippet.fromJson(e as Map<String, dynamic>))
+          .toList(),
   subscriptions:
       (json['subscriptions'] as List<dynamic>)
           .map((e) => Subscription.fromJson(e as Map<String, dynamic>))
@@ -128,6 +132,7 @@ Map<String, dynamic> _$InitialSnapshotToJson(InitialSnapshot instance) =>
           instance.serverTypingStartedWaitPeriodMilliseconds,
       'realm_emoji': instance.realmEmoji,
       'recent_private_conversations': instance.recentPrivateConversations,
+      'saved_snippets': instance.savedSnippets,
       'subscriptions': instance.subscriptions,
       'unread_msgs': instance.unreadMsgs,
       'streams': instance.streams,
diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart
index a2874c4c44..131a51991b 100644
--- a/lib/api/model/model.dart
+++ b/lib/api/model/model.dart
@@ -311,6 +311,30 @@ enum UserRole{
   }
 }
 
+/// An item in `saved_snippets` from the initial snapshot.
+///
+/// For docs, search for "saved_snippets:"
+/// in <https://zulip.com/api/register-queue>.
+@JsonSerializable(fieldRename: FieldRename.snake)
+class SavedSnippet {
+  SavedSnippet({
+    required this.id,
+    required this.title,
+    required this.content,
+    required this.dateCreated,
+  });
+
+  final int id;
+  final String title;
+  final String content;
+  final int dateCreated;
+
+  factory SavedSnippet.fromJson(Map<String, Object?> json) =>
+    _$SavedSnippetFromJson(json);
+
+  Map<String, dynamic> toJson() => _$SavedSnippetToJson(this);
+}
+
 /// As in `streams` in the initial snapshot.
 ///
 /// Not called `Stream` because dart:async uses that name.
diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart
index cddf78beb0..67fc606031 100644
--- a/lib/api/model/model.g.dart
+++ b/lib/api/model/model.g.dart
@@ -162,6 +162,21 @@ Map<String, dynamic> _$ProfileFieldUserDataToJson(
   'rendered_value': instance.renderedValue,
 };
 
+SavedSnippet _$SavedSnippetFromJson(Map<String, dynamic> json) => SavedSnippet(
+  id: (json['id'] as num).toInt(),
+  title: json['title'] as String,
+  content: json['content'] as String,
+  dateCreated: (json['date_created'] as num).toInt(),
+);
+
+Map<String, dynamic> _$SavedSnippetToJson(SavedSnippet instance) =>
+    <String, dynamic>{
+      'id': instance.id,
+      'title': instance.title,
+      'content': instance.content,
+      'date_created': instance.dateCreated,
+    };
+
 ZulipStream _$ZulipStreamFromJson(Map<String, dynamic> json) => ZulipStream(
   streamId: (json['stream_id'] as num).toInt(),
   name: json['name'] as String,
diff --git a/lib/api/route/saved_snippets.dart b/lib/api/route/saved_snippets.dart
new file mode 100644
index 0000000000..047a35051e
--- /dev/null
+++ b/lib/api/route/saved_snippets.dart
@@ -0,0 +1,31 @@
+import 'package:json_annotation/json_annotation.dart';
+
+import '../core.dart';
+
+part 'saved_snippets.g.dart';
+
+/// https://zulip.com/api/create-saved-snippet
+Future<CreateSavedSnippetResult> createSavedSnippet(ApiConnection connection, {
+  required String title,
+  required String content,
+}) {
+  assert(connection.zulipFeatureLevel! >= 297); // TODO(server-10)
+  return connection.post('createSavedSnippet', CreateSavedSnippetResult.fromJson, 'saved_snippets', {
+    'title': RawParameter(title),
+    'content': RawParameter(content),
+  });
+}
+
+@JsonSerializable(fieldRename: FieldRename.snake)
+class CreateSavedSnippetResult {
+  final int savedSnippetId;
+
+  CreateSavedSnippetResult({
+    required this.savedSnippetId,
+  });
+
+  factory CreateSavedSnippetResult.fromJson(Map<String, dynamic> json) =>
+    _$CreateSavedSnippetResultFromJson(json);
+
+  Map<String, dynamic> toJson() => _$CreateSavedSnippetResultToJson(this);
+}
diff --git a/lib/api/route/saved_snippets.g.dart b/lib/api/route/saved_snippets.g.dart
new file mode 100644
index 0000000000..aeb3c2a6c5
--- /dev/null
+++ b/lib/api/route/saved_snippets.g.dart
@@ -0,0 +1,19 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+// ignore_for_file: constant_identifier_names, unnecessary_cast
+
+part of 'saved_snippets.dart';
+
+// **************************************************************************
+// JsonSerializableGenerator
+// **************************************************************************
+
+CreateSavedSnippetResult _$CreateSavedSnippetResultFromJson(
+  Map<String, dynamic> json,
+) => CreateSavedSnippetResult(
+  savedSnippetId: (json['saved_snippet_id'] as num).toInt(),
+);
+
+Map<String, dynamic> _$CreateSavedSnippetResultToJson(
+  CreateSavedSnippetResult instance,
+) => <String, dynamic>{'saved_snippet_id': instance.savedSnippetId};
diff --git a/lib/model/saved_snippet.dart b/lib/model/saved_snippet.dart
new file mode 100644
index 0000000000..59c8347591
--- /dev/null
+++ b/lib/model/saved_snippet.dart
@@ -0,0 +1,38 @@
+import 'package:collection/collection.dart';
+
+import '../api/model/events.dart';
+import '../api/model/model.dart';
+import 'store.dart';
+
+mixin SavedSnippetStore {
+  Map<int, SavedSnippet> get savedSnippets;
+}
+
+class SavedSnippetStoreImpl extends PerAccountStoreBase with SavedSnippetStore {
+  SavedSnippetStoreImpl({
+    required super.core,
+    required Iterable<SavedSnippet> savedSnippets,
+  }) : _savedSnippets = {
+         for (final savedSnippet in savedSnippets)
+           savedSnippet.id: savedSnippet,
+       };
+
+  @override
+  late Map<int, SavedSnippet> savedSnippets = UnmodifiableMapView(_savedSnippets);
+  final Map<int, SavedSnippet> _savedSnippets;
+
+  void handleSavedSnippetsEvent(SavedSnippetsEvent event) {
+    switch (event) {
+      case SavedSnippetsAddEvent(:final savedSnippet):
+        _savedSnippets[savedSnippet.id] = savedSnippet;
+
+      case SavedSnippetsUpdateEvent(:final savedSnippet):
+        assert(_savedSnippets[savedSnippet.id]!.dateCreated
+                 == savedSnippet.dateCreated); // TODO(log)
+        _savedSnippets[savedSnippet.id] = savedSnippet;
+
+      case SavedSnippetsRemoveEvent(:final savedSnippetId):
+        _savedSnippets.remove(savedSnippetId);
+    }
+  }
+}
diff --git a/lib/model/store.dart b/lib/model/store.dart
index 297e4300bc..240e3ab4e4 100644
--- a/lib/model/store.dart
+++ b/lib/model/store.dart
@@ -29,6 +29,7 @@ import 'message_list.dart';
 import 'recent_dm_conversations.dart';
 import 'recent_senders.dart';
 import 'channel.dart';
+import 'saved_snippet.dart';
 import 'settings.dart';
 import 'typing_status.dart';
 import 'unreads.dart';
@@ -431,7 +432,7 @@ Uri? tryResolveUrl(Uri baseUrl, String reference) {
 /// This class does not attempt to poll an event queue
 /// to keep the data up to date.  For that behavior, see
 /// [UpdateMachine].
-class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStore, UserStore, ChannelStore, MessageStore {
+class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStore, SavedSnippetStore, UserStore, ChannelStore, MessageStore {
   /// Construct a store for the user's data, starting from the given snapshot.
   ///
   /// The global store must already have been updated with
@@ -486,6 +487,8 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor
       emoji: EmojiStoreImpl(
         core: core, allRealmEmoji: initialSnapshot.realmEmoji),
       userSettings: initialSnapshot.userSettings,
+      savedSnippets: SavedSnippetStoreImpl(
+        core: core, savedSnippets: initialSnapshot.savedSnippets ?? []),
       typingNotifier: TypingNotifier(
         core: core,
         typingStoppedWaitPeriod: Duration(
@@ -524,6 +527,7 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor
     required this.emailAddressVisibility,
     required EmojiStoreImpl emoji,
     required this.userSettings,
+    required SavedSnippetStoreImpl savedSnippets,
     required this.typingNotifier,
     required UserStoreImpl users,
     required this.typingStatus,
@@ -534,6 +538,7 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor
     required this.recentSenders,
   }) : _realmEmptyTopicDisplayName = realmEmptyTopicDisplayName,
        _emoji = emoji,
+       _savedSnippets = savedSnippets,
        _users = users,
        _channels = channels,
        _messages = messages;
@@ -624,6 +629,10 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor
 
   final UserSettings? userSettings; // TODO(server-5)
 
+  @override
+  Map<int, SavedSnippet> get savedSnippets => _savedSnippets.savedSnippets;
+  final SavedSnippetStoreImpl _savedSnippets;
+
   final TypingNotifier typingNotifier;
 
   ////////////////////////////////
@@ -871,6 +880,11 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor
         autocompleteViewManager.handleRealmUserUpdateEvent(event);
         notifyListeners();
 
+      case SavedSnippetsEvent():
+        assert(debugLog('server event: saved_snippets/${event.op}'));
+        _savedSnippets.handleSavedSnippetsEvent(event);
+        notifyListeners();
+
       case ChannelEvent():
         assert(debugLog("server event: stream/${event.op}"));
         _channels.handleChannelEvent(event);
diff --git a/test/api/model/model_checks.dart b/test/api/model/model_checks.dart
index 1a17f70f60..b90238ae35 100644
--- a/test/api/model/model_checks.dart
+++ b/test/api/model/model_checks.dart
@@ -21,6 +21,13 @@ extension UserChecks on Subject<User> {
   Subject<bool> get isSystemBot => has((x) => x.isSystemBot, 'isSystemBot');
 }
 
+extension SavedSnippetChecks on Subject<SavedSnippet> {
+  Subject<int> get id => has((x) => x.id, 'id');
+  Subject<String> get title => has((x) => x.title, 'title');
+  Subject<String> get content => has((x) => x.content, 'content');
+  Subject<int> get dateCreated => has((x) => x.dateCreated, 'dateCreated');
+}
+
 extension ZulipStreamChecks on Subject<ZulipStream> {
 }
 
diff --git a/test/api/route/route_checks.dart b/test/api/route/route_checks.dart
index 6d310ab200..1ecd90e9c8 100644
--- a/test/api/route/route_checks.dart
+++ b/test/api/route/route_checks.dart
@@ -1,8 +1,12 @@
 import 'package:checks/checks.dart';
 import 'package:zulip/api/route/messages.dart';
+import 'package:zulip/api/route/saved_snippets.dart';
 
 extension SendMessageResultChecks on Subject<SendMessageResult> {
   Subject<int> get id => has((e) => e.id, 'id');
 }
+extension CreateSavedSnippetResultChecks on Subject<CreateSavedSnippetResult> {
+  Subject<int> get savedSnippetId => has((e) => e.savedSnippetId, 'savedSnippetId');
+}
 
 // TODO add similar extensions for other classes in api/route/*.dart
diff --git a/test/api/route/saved_snippets_test.dart b/test/api/route/saved_snippets_test.dart
new file mode 100644
index 0000000000..3eeccbde8b
--- /dev/null
+++ b/test/api/route/saved_snippets_test.dart
@@ -0,0 +1,27 @@
+import 'package:checks/checks.dart';
+import 'package:http/http.dart' as http;
+import 'package:flutter_test/flutter_test.dart';
+import 'package:zulip/api/route/saved_snippets.dart';
+
+import '../../stdlib_checks.dart';
+import '../fake_api.dart';
+import 'route_checks.dart';
+
+void main() {
+  test('smoke', () async {
+    return FakeApiConnection.with_((connection) async {
+      connection.prepare(
+        json: CreateSavedSnippetResult(savedSnippetId: 123).toJson());
+      final result = await createSavedSnippet(connection,
+        title: 'test saved snippet', content: 'content');
+      check(connection.takeRequests()).single.isA<http.Request>()
+        ..method.equals('POST')
+        ..url.path.equals('/api/v1/saved_snippets')
+        ..bodyFields.deepEquals({
+          'title': 'test saved snippet',
+          'content': 'content',
+        });
+      check(result).savedSnippetId.equals(123);
+    });
+  });
+}
diff --git a/test/example_data.dart b/test/example_data.dart
index c80fcf4528..d803196269 100644
--- a/test/example_data.dart
+++ b/test/example_data.dart
@@ -286,6 +286,28 @@ final User thirdUser = user(fullName: 'Third User');
 
 final User fourthUser  = user(fullName: 'Fourth User');
 
+////////////////////////////////////////////////////////////////
+// Data attached to the self-account on the realm
+//
+
+int _nextSavedSnippetId() => _lastSavedSnippetId++;
+int _lastSavedSnippetId = 1;
+
+SavedSnippet savedSnippet({
+  int? id,
+  String? title,
+  String? content,
+  int? dateCreated,
+}) {
+  _checkPositive(id, 'saved snippet ID');
+  return SavedSnippet(
+    id: id ?? _nextSavedSnippetId(),
+    title: title ?? 'A saved snippet',
+    content: content ?? 'foo bar baz',
+    dateCreated: dateCreated ?? 1234567890, // TODO generate timestamp
+  );
+}
+
 ////////////////////////////////////////////////////////////////
 // Streams and subscriptions.
 //
@@ -959,6 +981,7 @@ InitialSnapshot initialSnapshot({
   int? serverTypingStartedWaitPeriodMilliseconds,
   Map<String, RealmEmojiItem>? realmEmoji,
   List<RecentDmConversation>? recentPrivateConversations,
+  List<SavedSnippet>? savedSnippets,
   List<Subscription>? subscriptions,
   UnreadMessagesSnapshot? unreadMsgs,
   List<ZulipStream>? streams,
@@ -994,6 +1017,7 @@ InitialSnapshot initialSnapshot({
       serverTypingStartedWaitPeriodMilliseconds ?? 10000,
     realmEmoji: realmEmoji ?? {},
     recentPrivateConversations: recentPrivateConversations ?? [],
+    savedSnippets: savedSnippets ?? [],
     subscriptions: subscriptions ?? [], // TODO add subscriptions to default
     unreadMsgs: unreadMsgs ?? _unreadMsgs(),
     streams: streams ?? [], // TODO add streams to default
diff --git a/test/model/saved_snippet_test.dart b/test/model/saved_snippet_test.dart
new file mode 100644
index 0000000000..3c6756f977
--- /dev/null
+++ b/test/model/saved_snippet_test.dart
@@ -0,0 +1,44 @@
+import 'package:checks/checks.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:zulip/api/model/events.dart';
+import 'package:zulip/api/model/model.dart';
+
+import '../api/model/model_checks.dart';
+import '../example_data.dart' as eg;
+import 'store_checks.dart';
+
+void main() {
+  test('handleSavedSnippetsEvent', () async {
+    final store = eg.store(initialSnapshot: eg.initialSnapshot(
+      savedSnippets: [eg.savedSnippet(id: 101)]));
+    check(store).savedSnippets.values.single.id.equals(101);
+
+    await store.handleEvent(SavedSnippetsAddEvent(id: 1,
+      savedSnippet: eg.savedSnippet(
+        id: 102,
+        title: 'foo title',
+        content: 'foo content',
+      )));
+    check(store).savedSnippets.values.deepEquals(<Condition<Object?>>[
+      (it) => it.isA<SavedSnippet>().id.equals(101),
+      (it) => it.isA<SavedSnippet>()..id.equals(102)
+                                    ..title.equals('foo title')
+                                    ..content.equals('foo content')
+    ]);
+
+    await store.handleEvent(SavedSnippetsRemoveEvent(id: 1, savedSnippetId: 101));
+    check(store).savedSnippets.values.single.id.equals(102);
+
+    await store.handleEvent(SavedSnippetsUpdateEvent(id: 1,
+      savedSnippet: eg.savedSnippet(
+        id: 102,
+        title: 'bar title',
+        content: 'bar content',
+        dateCreated: store.savedSnippets.values.single.dateCreated,
+      )));
+    check(store).savedSnippets.values.single
+      ..id.equals(102)
+      ..title.equals('bar title')
+      ..content.equals('bar content');
+  });
+}
diff --git a/test/model/store_checks.dart b/test/model/store_checks.dart
index 32379a6f06..93e24dffdd 100644
--- a/test/model/store_checks.dart
+++ b/test/model/store_checks.dart
@@ -56,6 +56,7 @@ extension PerAccountStoreChecks on Subject<PerAccountStore> {
   Subject<Account> get account => has((x) => x.account, 'account');
   Subject<int> get selfUserId => has((x) => x.selfUserId, 'selfUserId');
   Subject<UserSettings?> get userSettings => has((x) => x.userSettings, 'userSettings');
+  Subject<Map<int, SavedSnippet>> get savedSnippets => has((x) => x.savedSnippets, 'savedSnippets');
   Subject<Map<int, ZulipStream>> get streams => has((x) => x.streams, 'streams');
   Subject<Map<String, ZulipStream>> get streamsByName => has((x) => x.streamsByName, 'streamsByName');
   Subject<Map<int, Subscription>> get subscriptions => has((x) => x.subscriptions, 'subscriptions');


Reviews:
user: login: chrisbobbe
body: Thanks!! Small comments below.
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-20T19:42:28Z
user: login: chrisbobbe
body: Thanks! Small comments below.
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-20T22:22:14Z
user: login: chrisbobbe
body: Thanks! One nit below, and I'll mark for Greg's review.
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-20T23:15:13Z
user: login: PIG208
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-20T23:18:32Z
user: login: gnprice
body: Thanks to you both! I've done a quick skim, and this structure looks good. I like the organization with the separate API and model/store commits (as per https://github.com/zulip/zulip-flutter/pull/1511#discussion_r2098733072), with this to decouple the API event commit from the store changes:
```dart
      case SavedSnippetsEvent():
        // TODO handle
        break;
```

I'll aim to do a full review tomorrow.

state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-21T05:24:58Z
user: login: gnprice
body: OK, and here's a full review — just small comments below.

(And I see you've already revised to handle my one comment above, thanks.)

state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-21T22:13:15Z
user: login: PIG208
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T00:49:03Z

Review comments:
diff_hunk: @@ -37,6 +37,13 @@ sealed class Event {
           case 'update': return RealmUserUpdateEvent.fromJson(json);
           default: return UnexpectedEvent.fromJson(json);
         }
+      case 'saved_snippets':
path: lib/api/model/events.dart
user: login: chrisbobbe
body: ```
api: Add save_snippets events and handle live-updates
```

Commit-message nit: `saved_snippets`
created_at: 2025-05-20T19:24:01Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,35 @@
+import 'package:collection/collection.dart';
+
+import '../api/model/events.dart';
+import '../api/model/model.dart';
+
+mixin SavedSnippetStore {
+  Map<int, SavedSnippet> get savedSnippets;
+}
+
+class SavedSnippetStoreImpl with SavedSnippetStore {
path: lib/model/saved_snippet.dart
user: login: chrisbobbe
body: Normally a substore would extend `PerAccountStoreBase`…it seems we don't depend on that here though 🙂 but maybe it's helpful to do it anyway, in case it's needed in the future, and also as an example for future substore implementations?

```diff
diff --git lib/model/saved_snippet.dart lib/model/saved_snippet.dart
index 66b50781d..92cb253ef 100644
--- lib/model/saved_snippet.dart
+++ lib/model/saved_snippet.dart
@@ -2,14 +2,18 @@ import 'package:collection/collection.dart';
 
 import '../api/model/events.dart';
 import '../api/model/model.dart';
+import 'store.dart';
 
 mixin SavedSnippetStore {
   Map<int, SavedSnippet> get savedSnippets;
 }
 
-class SavedSnippetStoreImpl with SavedSnippetStore {
-  SavedSnippetStoreImpl({required Iterable<SavedSnippet> savedSnippets})
-    : _savedSnippets = {
+class SavedSnippetStoreImpl extends PerAccountStoreBase with SavedSnippetStore {
+  SavedSnippetStoreImpl({
+    required super.core,
+    required Iterable<SavedSnippet> savedSnippets,
+  }) :
+      _savedSnippets = {
         for (final savedSnippet in savedSnippets)
           savedSnippet.id: savedSnippet,
       };
```
created_at: 2025-05-20T19:32:55Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,44 @@
+import 'package:checks/checks.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:zulip/api/model/events.dart';
+import 'package:zulip/api/model/model.dart';
+
+import '../api/model/model_checks.dart';
+import '../example_data.dart' as eg;
+import 'store_checks.dart';
+
+void main() {
+  test('handleSavedSnippetsEvent', () async {
+    final store = eg.store(initialSnapshot: eg.initialSnapshot(
+      savedSnippets: [eg.savedSnippet(id: 101)]));
+    check(store).savedSnippets.values.single.id.equals(101);
+
+    await store.handleEvent(SavedSnippetsAddEvent(id: 1,savedSnippet:
path: test/model/saved_snippet.dart
user: login: chrisbobbe
body: nit: space after comma; actually maybe put `savedSnippet:` on the next line, with `eg.savedSnippet(`?
created_at: 2025-05-20T19:33:45Z
author_association: COLLABORATOR
diff_hunk: @@ -37,6 +37,13 @@ sealed class Event {
           case 'update': return RealmUserUpdateEvent.fromJson(json);
           default: return UnexpectedEvent.fromJson(json);
         }
+      case 'saved_snippets':
path: lib/api/model/events.dart
user: login: chrisbobbe
body: Also this is labeled as an "api:" commit, but it also adds to model code; how about separate `api:` and `model:` commits?

(Or perhaps just one `model:` commit with the API-binding additions lumped into that same commit—but if doing that, it would be logical to lump _all_ the new API-binding code into that commit, including the initial-snapshot code.)
created_at: 2025-05-20T19:42:01Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,30 @@
+import 'package:json_annotation/json_annotation.dart';
+
+import '../core.dart';
+
+part 'saved_snippets.g.dart';
+
+/// https://zulip.com/api/create-saved-snippet
+Future<CreateSavedSnippetResult> createSavedSnippet(ApiConnection connection, {
path: lib/api/route/saved_snippets.dart
user: login: chrisbobbe
body: ```
api: Add createSavedSnippet route
```

This needs a simple smoke test in test/api/route/.
created_at: 2025-05-20T22:16:13Z
author_association: COLLABORATOR
diff_hunk: @@ -868,6 +877,11 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor
         autocompleteViewManager.handleRealmUserUpdateEvent(event);
         notifyListeners();
 
+      case SavedSnippetsEvent():
+        assert(debugLog('server event: saved_snippets/${event.op}'));
+        _savedSnippets.handleSavedSnippetsEvent(event);
+        notifyListeners();
path: lib/model/store.dart
user: login: chrisbobbe
body: Ah I guess this case needs to be handled (as a no-op) in the earlier commit—

```
api: Add saved_snippets events
```

—to satisfy the analyzer there.
created_at: 2025-05-20T22:20:39Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,38 @@
+import 'package:collection/collection.dart';
+
+import '../api/model/events.dart';
+import '../api/model/model.dart';
+import 'store.dart';
+
+mixin SavedSnippetStore {
+  Map<int, SavedSnippet> get savedSnippets;
+}
+
+class SavedSnippetStoreImpl extends PerAccountStoreBase with SavedSnippetStore {
path: lib/model/saved_snippet.dart
user: login: chrisbobbe
body: ```
model: Handle live-updates to savedSnippets
```

Commit-message nit: this commit does more than just handle live-updates to savedSnippets—it's what adds savedSnippets into the codebase in the first place :)

Could be:

```
model: Add PerAccountStore.savedSnippets, updating with events
```

or similar.
created_at: 2025-05-20T22:22:07Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,25 @@
+import 'package:checks/checks.dart';
+import 'package:http/http.dart' as http;
+import 'package:flutter_test/flutter_test.dart';
+import 'package:zulip/api/route/saved_snippets.dart';
+
+import '../../stdlib_checks.dart';
+import '../fake_api.dart';
+import '../route/route_checks.dart';
+
+void main() {
+  test('smoke', () async {
+    return FakeApiConnection.with_((connection) async {
+      connection.prepare(
+        json: CreateSavedSnippetResult(savedSnippetId: 123).toJson());
+      final result = await createSavedSnippet(connection,
+        title: 'test saved snippet', content: 'content');
+      check(result).savedSnippetId.equals(123);
+      check(connection.takeRequests()).single.isA<http.Request>()
+        .bodyFields.deepEquals({
+        'title': 'test saved snippet',
+        'content': 'content',
+      });
path: test/api/model/saved_snippets_test.dart
user: login: chrisbobbe
body: ```suggestion
      check(connection.takeRequests()).single.isA<http.Request>()
        .bodyFields.deepEquals({
          'title': 'test saved snippet',
          'content': 'content',
        });
```

(I think)
created_at: 2025-05-20T23:15:03Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,25 @@
+import 'package:checks/checks.dart';
+import 'package:http/http.dart' as http;
+import 'package:flutter_test/flutter_test.dart';
+import 'package:zulip/api/route/saved_snippets.dart';
+
+import '../../stdlib_checks.dart';
+import '../fake_api.dart';
+import '../route/route_checks.dart';
+
+void main() {
+  test('smoke', () async {
+    return FakeApiConnection.with_((connection) async {
+      connection.prepare(
+        json: CreateSavedSnippetResult(savedSnippetId: 123).toJson());
+      final result = await createSavedSnippet(connection,
+        title: 'test saved snippet', content: 'content');
+      check(result).savedSnippetId.equals(123);
+      check(connection.takeRequests()).single.isA<http.Request>()
+        .bodyFields.deepEquals({
+        'title': 'test saved snippet',
+        'content': 'content',
+      });
path: test/api/model/saved_snippets_test.dart
user: login: PIG208
body: Oh right. Oops 😬
created_at: 2025-05-20T23:18:32Z
author_association: MEMBER
diff_hunk: @@ -336,6 +343,71 @@ class RealmUserUpdateEvent extends RealmUserEvent {
   Map<String, dynamic> toJson() => _$RealmUserUpdateEventToJson(this);
 }
 
+/// A Zulip event of type `saved_snippets`.
+///
+/// The corresponding API docs are in several places for
+/// different values of `op`; see subclasses.
+sealed class SavedSnippetsEvent extends Event {
path: lib/api/model/events.dart
user: login: gnprice
body: nit: It looks like these docs are actually next to each other, so this class can usefully link to the one that comes first. (The subclasses can still link to the individual sections.)

This wording was originally for `realm_user`, which is actually scattered across two widely-separated places in https://zulip.com/api/get-events . (And I see I wrote it at the same time for `stream` but it's not true of `stream` now; not sure if that was an oversight or that one got fixed since then.)
created_at: 2025-05-21T05:20:05Z
author_association: MEMBER
diff_hunk: @@ -619,6 +624,10 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor
   ////////////////////////////////
   // Data attached to the self-account on the realm.
 
+  @override
+  Map<int, SavedSnippet> get savedSnippets => _savedSnippets.savedSnippets;
+  final SavedSnippetStoreImpl _savedSnippets;
+
   final UserSettings? userSettings; // TODO(server-5)
path: lib/model/store.dart
user: login: gnprice
body: nit: let's put the new members after userSettings — the user's overall settings are a more fundamental piece of data than the saved snippets specifically (which are basically a particular setting)

created_at: 2025-05-21T21:48:33Z
author_association: MEMBER
diff_hunk: @@ -21,6 +21,12 @@ extension UserChecks on Subject<User> {
   Subject<bool> get isSystemBot => has((x) => x.isSystemBot, 'isSystemBot');
 }
 
+extension SavedSnippetChecks on Subject<SavedSnippet> {
+  Subject<int> get id => has((x) => x.id, 'id');
+  Subject<String> get title => has((x) => x.title, 'title');
+  Subject<String> get content => has((x) => x.content, 'content');
+}
path: test/api/model/model_checks.dart
user: login: gnprice
body: nit: let's include the remaining field too, even if tests don't currently use it — completeness saves the need to go back and add it later

BTW in general I find it efficient to write these in batch: first get to a set of lines like
```dart
  int id;
  String title;
  String content;
  int dateCreated;
```

Then go into multi-cursor mode, with a cursor at the start of each of those lines: for example, in Android Studio on Linux, I double-tap Ctrl, hold the second tap, then hit Down a few times to make more cursors.

Then Ctrl+Left / Ctrl+Right to move by a word at a time; Ctrl+Shift+Right to do so while selecting, to select the name; the usual Ctrl+C for copy will copy the set of different names, and Ctrl+V will paste the different names on their respective lines. So the rest of the line, like `Subject<`, `> get`, `=> has((x) => x.`, `, '`, `')`, I type just once.

created_at: 2025-05-21T21:54:52Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,44 @@
+import 'package:checks/checks.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:zulip/api/model/events.dart';
+import 'package:zulip/api/model/model.dart';
+
+import '../api/model/model_checks.dart';
+import '../example_data.dart' as eg;
+import 'store_checks.dart';
+
+void main() {
path: test/model/saved_snippet.dart
user: login: gnprice
body: filename should end with `_test.dart`

Otherwise I believe `flutter test` won't include it. (Though even if it did, following a uniform pattern is helpful for us human readers.)

created_at: 2025-05-21T21:56:22Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,30 @@
+import 'package:json_annotation/json_annotation.dart';
+
+import '../core.dart';
+
+part 'saved_snippets.g.dart';
+
+/// https://zulip.com/api/create-saved-snippet
+Future<CreateSavedSnippetResult> createSavedSnippet(ApiConnection connection, {
+  required String title,
+  required String content,
+}) {
+  return connection.post('createSavedSnippet', CreateSavedSnippetResult.fromJson, 'saved_snippets', {
path: lib/api/route/saved_snippets.dart
user: login: gnprice
body: ```suggestion
}) {
  assert(connection.zulipFeatureLevel! >= 297); // TODO(server-10)
  return connection.post('createSavedSnippet', CreateSavedSnippetResult.fromJson, 'saved_snippets', {
```

That doubles as a reminder, when writing code that calls this endpoint, of what feature level to check for before offering the feature in the UI.
created_at: 2025-05-21T22:05:23Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,25 @@
+import 'package:checks/checks.dart';
+import 'package:http/http.dart' as http;
+import 'package:flutter_test/flutter_test.dart';
+import 'package:zulip/api/route/saved_snippets.dart';
+
+import '../../stdlib_checks.dart';
+import '../fake_api.dart';
+import '../route/route_checks.dart';
+
+void main() {
+  test('smoke', () async {
+    return FakeApiConnection.with_((connection) async {
+      connection.prepare(
+        json: CreateSavedSnippetResult(savedSnippetId: 123).toJson());
+      final result = await createSavedSnippet(connection,
path: test/api/model/saved_snippets_test.dart
user: login: gnprice
body: this file goes in test/api/route/ :slightly_smiling_face: (not …/model/)
created_at: 2025-05-21T22:06:15Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,25 @@
+import 'package:checks/checks.dart';
+import 'package:http/http.dart' as http;
+import 'package:flutter_test/flutter_test.dart';
+import 'package:zulip/api/route/saved_snippets.dart';
+
+import '../../stdlib_checks.dart';
+import '../fake_api.dart';
+import '../route/route_checks.dart';
+
+void main() {
+  test('smoke', () async {
+    return FakeApiConnection.with_((connection) async {
+      connection.prepare(
+        json: CreateSavedSnippetResult(savedSnippetId: 123).toJson());
+      final result = await createSavedSnippet(connection,
+        title: 'test saved snippet', content: 'content');
+      check(result).savedSnippetId.equals(123);
+      check(connection.takeRequests()).single.isA<http.Request>()
+        .bodyFields.deepEquals({
path: test/api/model/saved_snippets_test.dart
user: login: gnprice
body: As long as we're writing this test, it's only a couple more lines to have it check the other key details of the request too: the method and URL path. So let's include those too.
created_at: 2025-05-21T22:09:24Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,25 @@
+import 'package:checks/checks.dart';
+import 'package:http/http.dart' as http;
+import 'package:flutter_test/flutter_test.dart';
+import 'package:zulip/api/route/saved_snippets.dart';
+
+import '../../stdlib_checks.dart';
+import '../fake_api.dart';
+import '../route/route_checks.dart';
+
+void main() {
+  test('smoke', () async {
+    return FakeApiConnection.with_((connection) async {
+      connection.prepare(
+        json: CreateSavedSnippetResult(savedSnippetId: 123).toJson());
+      final result = await createSavedSnippet(connection,
+        title: 'test saved snippet', content: 'content');
+      check(result).savedSnippetId.equals(123);
+      check(connection.takeRequests()).single.isA<http.Request>()
path: test/api/model/saved_snippets_test.dart
user: login: gnprice
body: nit: the order is a bit more logical if we check the request first, then the response — after all, the request had to happen before we would receive the response
created_at: 2025-05-21T22:10:54Z
author_association: MEMBER
diff_hunk: @@ -21,6 +21,12 @@ extension UserChecks on Subject<User> {
   Subject<bool> get isSystemBot => has((x) => x.isSystemBot, 'isSystemBot');
 }
 
+extension SavedSnippetChecks on Subject<SavedSnippet> {
+  Subject<int> get id => has((x) => x.id, 'id');
+  Subject<String> get title => has((x) => x.title, 'title');
+  Subject<String> get content => has((x) => x.content, 'content');
+}
path: test/api/model/model_checks.dart
user: login: PIG208
body: That's neat! I usually went line by line replacing the symbols and the string. This sounds more efficient when you have multiple fields.
created_at: 2025-05-22T00:49:03Z
author_association: MEMBER

PR comments:
user: login: PIG208
created_at: 2025-05-20T20:45:19Z
body: Thanks! Updated the PR.
author_association: MEMBER
user: login: PIG208
created_at: 2025-05-20T23:01:55Z
body: Thanks! Pushed an update. 
author_association: MEMBER
user: login: PIG208
created_at: 2025-05-22T01:00:58Z
body: Thanks! Updated the PR.
author_association: MEMBER
user: login: gnprice
created_at: 2025-05-22T22:48:22Z
body: Thanks! Looks good; merging.
author_association: MEMBER