refactor notifications setup so we can add sib notifs
What changed, and why it matters
This commit is a feature refactor that adds a new in-app notification system for ShopInBit support tickets. It introduces a local database table for notifications, merges those notifications into the existing notification feed, and marks tickets/notifications as read when the user views them. There is no clear security vulnerability in the diff, but the change touches notification plumbing and local database migrations, so it carries normal implementation risk.
Treat as a normal feature refactor. Review the migration path for users upgrading from schema version 1 (the `if (from < 2)` branch now creates three tables and two indexes, which is correct but should be tested). Verify that `markTicketRead` clamps `readAt` upward to `lastAgentMessageAt` as intended and that `viewingTicketId` visibility checks prevent spurious OS notifications without suppressing legitimate ones. No security patch or incident response is indicated by the supplied materials.
Security signals we found
New local database table for notifications with scope/target indexing
Database schema migration from version 2 to 3
Notification read-state now derived from comparing server-provided `lastAgentMessageAt` with local `lastReadAt`
OS notification id allocation race fixed by returning id synchronously before async persist
Visibility-aware suppression of OS banners when user is currently viewing the relevant ticket
No input validation, crypto, network trust, or permission changes visible in the supplied diff
Evidence from the diff
The commit refactors Stack Wallet’s notification layer to support per-account ShopInBit (SIB) ticket notifications. Key changes: (1) adds an AppNotifications Drift table plus DAO with scope/target indexes; (2) bumps the shared database schema version from 2 to 3 and creates the new table/indexes in onUpgrade; (3) adds lastReadAt to ShopInBitTickets and derives unread state from lastAgentMessageAt; (4) introduces NotificationFeedEntry to merge legacy Hive-based notifications with new AppNotification rows; (5) updates mobile/desktop notification views to use the merged feed; (6) adds ShopInBitService methods markTicketRead, markAllNotificationsRead, _maybeNotifyNewReply, and a viewingTicketId visibility flag so on-screen replies skip OS banners and are auto-marked read; (7) fixes a race in Prefs.incrementCurrentNotificationIndex() by returning the allocated id synchronously; (8) adds NotificationApi.showLocalOnly() and _showOsNotification() helpers. No explicit security fix or vulnerability disclosure is present in the commit message or diff.
Changed components
lib/db/drift/shared_db/shared_database.dartlib/db/drift/shared_db/shared_database.g.dartlib/db/drift/shared_db/tables/notifications.dartlib/db/drift/shared_db/tables/shopin_bit_tickets.dartlib/notifications/notification_card.dartlib/notifications/notification_card_layout.dartlib/notifications/notification_feed_entry.dartlib/notifications/notification_feed_entry_card.dartlib/notifications/shopinbit_notification_card.dartlib/pages/home_view/home_view.dartlib/pages/notification_views/notifications_view.dartlib/pages/shopinbit/shopinbit_ticket_detail.dartlib/pages_desktop_specific/desktop_menu_item.dartlib/pages_desktop_specific/notifications/desktop_notifications_view.dartlib/providers/global/shopin_bit_service_provider.dartlib/providers/ui/notification_feed_provider.dartlib/services/notifications_api.dartlib/services/shopinbit/shopinbit_service.dartlib/utilities/prefs.dartInspect captured patch +1737 / −282
diff --git a/lib/db/drift/shared_db/shared_database.dart b/lib/db/drift/shared_db/shared_database.dart
index 70a9aca..83543b6 100644
--- a/lib/db/drift/shared_db/shared_database.dart
+++ b/lib/db/drift/shared_db/shared_database.dart
@@ -6,6 +6,7 @@ import "../../../models/shopinbit/shopinbit_enums.dart";
import "../../../services/shopinbit/src/models/message.dart";
import '../../../utilities/stack_file_system.dart';
import 'tables/cakepay_orders.dart';
+import 'tables/notifications.dart';
import 'tables/shopin_bit_settings.dart';
import 'tables/shopin_bit_tickets.dart';
@@ -27,22 +28,30 @@ abstract final class SharedDrift {
}
@DriftDatabase(
- tables: [CakepayOrders, ShopInBitSettings, ShopInBitTickets],
- daos: [ShopInBitSettingsDao, ShopInBitTicketsDao],
+ tables: [
+ CakepayOrders,
+ ShopInBitSettings,
+ ShopInBitTickets,
+ AppNotifications,
+ ],
+ daos: [ShopInBitSettingsDao, ShopInBitTicketsDao, AppNotificationsDao],
)
final class SharedDatabase extends _$SharedDatabase {
SharedDatabase._([QueryExecutor? executor])
: super(executor ?? _openConnection());
@override
- int get schemaVersion => 2;
+ int get schemaVersion => 3;
@override
MigrationStrategy get migration => MigrationStrategy(
onUpgrade: (m, from, to) async {
- if (from == 1 && to == 2) {
+ if (from < 2) {
await m.createTable(shopInBitSettings);
await m.createTable(shopInBitTickets);
+ await m.createTable(appNotifications);
+ await m.createIndex(appNotificationsScope);
+ await m.createIndex(appNotificationsTarget);
}
},
);
@@ -108,6 +117,23 @@ class ShopInBitTicketsDao extends DatabaseAccessor<SharedDatabase>
return rows > 0;
}
+ Future<bool> markRead(int apiTicketId, [DateTime? readAt]) async {
+ final int rows =
+ await (update(shopInBitTickets)..where(
+ (t) =>
+ t.apiTicketId.equals(apiTicketId) &
+ t.lastAgentMessageAt.isNotNull() &
+ (t.lastReadAt.isNull() |
+ t.lastAgentMessageAt.isBiggerThan(t.lastReadAt)),
+ ))
+ .write(
+ ShopInBitTicketsCompanion(
+ lastReadAt: Value(readAt ?? DateTime.now().toUtc()),
+ ),
+ );
+ return rows > 0;
+ }
+
Future<int> deleteByApiId(int apiTicketId) {
return (delete(
shopInBitTickets,
@@ -121,6 +147,87 @@ class ShopInBitTicketsDao extends DatabaseAccessor<SharedDatabase>
}
}
+@DriftAccessor(tables: [AppNotifications])
+class AppNotificationsDao extends DatabaseAccessor<SharedDatabase>
+ with _$AppNotificationsDaoMixin {
+ AppNotificationsDao(super.db);
+
+ Stream<List<AppNotification>> watchByScope(
+ AppNotificationType type,
+ String scopeId,
+ ) {
+ return (select(appNotifications)
+ ..where((t) => t.type.equalsValue(type) & t.scopeId.equals(scopeId))
+ ..orderBy([
+ (t) => OrderingTerm.desc(t.createdAt),
+ (t) => OrderingTerm.desc(t.id),
+ ]))
+ .watch();
+ }
+
+ Expression<bool> _unreadScope({AppNotificationType? type, String? scopeId}) {
+ Expression<bool> pred = appNotifications.read.equals(false);
+ if (type != null) {
+ pred = pred & appNotifications.type.equalsValue(type);
+ }
+ if (scopeId != null) {
+ pred = pred & appNotifications.scopeId.equals(scopeId);
+ }
+ return pred;
+ }
+
+ Stream<int> watchUnreadCount({AppNotificationType? type, String? scopeId}) {
+ final count = countAll();
+ final query = selectOnly(appNotifications)
+ ..addColumns([count])
+ ..where(_unreadScope(type: type, scopeId: scopeId));
+ return query.watchSingle().map((row) => row.read(count) ?? 0);
+ }
+
+ Future<void> add(AppNotificationsCompanion row) async {
+ await into(appNotifications).insert(row);
+ }
+
+ Future<int> markReadByTarget(AppNotificationType type, String targetId) {
+ return (update(appNotifications)..where(
+ (t) =>
+ t.type.equalsValue(type) &
+ t.targetId.equals(targetId) &
+ t.read.equals(false),
+ ))
+ .write(const AppNotificationsCompanion(read: Value(true)));
+ }
+
+ /// Mark all unread notifications read, optionally scoped to [type]/[scopeId].
+ Future<int> markAllRead({AppNotificationType? type, String? scopeId}) {
+ return (update(appNotifications)
+ ..where((_) => _unreadScope(type: type, scopeId: scopeId)))
+ .write(const AppNotificationsCompanion(read: Value(true)));
+ }
+
+ Future<void> pruneScope(
+ AppNotificationType type,
+ String scopeId, {
+ int keep = 200,
+ }) async {
+ final rows =
+ await (select(appNotifications)
+ ..where(
+ (t) => t.type.equalsValue(type) & t.scopeId.equals(scopeId),
+ )
+ ..orderBy([(t) => OrderingTerm.desc(t.id)]))
+ .get();
+ if (rows.length <= keep) return;
+ final prunable = rows
+ .skip(keep)
+ .where((r) => r.read)
+ .map((r) => r.id)
+ .toList();
+ if (prunable.isEmpty) return;
+ await (delete(appNotifications)..where((t) => t.id.isIn(prunable))).go();
+ }
+}
+
@DriftAccessor(tables: [ShopInBitSettings])
class ShopInBitSettingsDao extends DatabaseAccessor<SharedDatabase>
with _$ShopInBitSettingsDaoMixin {
@@ -234,3 +341,12 @@ extension ShopInBitSettingGuidelines on ShopInBitSetting {
.car => carGuidelinesAccepted,
};
}
+
+extension ShopInBitTicketUnread on ShopInBitTicket {
+ bool get hasUnreadAgentMessage {
+ final DateTime? lastAgent = lastAgentMessageAt;
+ if (lastAgent == null) return false;
+ final DateTime? read = lastReadAt;
+ return read == null || lastAgent.isAfter(read);
+ }
+}
diff --git a/lib/db/drift/shared_db/shared_database.g.dart b/lib/db/drift/shared_db/shared_database.g.dart
index 693442e..ecb93df 100644
--- a/lib/db/drift/shared_db/shared_database.g.dart
+++ b/lib/db/drift/shared_db/shared_database.g.dart
@@ -936,6 +936,15 @@ class $ShopInBitTicketsTable extends ShopInBitTickets
ShopInBitTickets.dateConverter.toSql(DateTime.now()),
).withConverter<DateTime>($ShopInBitTicketsTable.$converterupdatedAt);
@override
+ late final GeneratedColumnWithTypeConverter<DateTime?, String> lastReadAt =
+ GeneratedColumn<String>(
+ 'last_read_at',
+ aliasedName,
+ true,
+ type: DriftSqlType.string,
+ requiredDuringInsert: false,
+ ).withConverter<DateTime?>($ShopInBitTicketsTable.$converterlastReadAtn);
+ @override
List<GeneratedColumn> get $columns => [
apiTicketId,
customerKey,
@@ -954,6 +963,7 @@ class $ShopInBitTicketsTable extends ShopInBitTickets
messages,
createdAt,
updatedAt,
+ lastReadAt,
];
@override
String get aliasedName => _alias ?? actualTableName;
@@ -1162,6 +1172,12 @@ class $ShopInBitTicketsTable extends ShopInBitTickets
data['${effectivePrefix}updated_at'],
)!,
),
+ lastReadAt: $ShopInBitTicketsTable.$converterlastReadAtn.fromSql(
+ attachedDatabase.typeMapping.read(
+ DriftSqlType.string,
+ data['${effectivePrefix}last_read_at'],
+ ),
+ ),
);
}
@@ -1188,6 +1204,10 @@ class $ShopInBitTicketsTable extends ShopInBitTickets
ShopInBitTickets.dateConverter;
static TypeConverter<DateTime, String> $converterupdatedAt =
ShopInBitTickets.dateConverter;
+ static TypeConverter<DateTime, String> $converterlastReadAt =
+ ShopInBitTickets.dateConverter;
+ static TypeConverter<DateTime?, String?> $converterlastReadAtn =
+ NullAwareTypeConverter.wrap($converterlastReadAt);
@override
bool get withoutRowId => true;
}
@@ -1210,6 +1230,7 @@ class ShopInBitTicket extends DataClass implements Insertable<ShopInBitTicket> {
final List<TicketMessage> messages;
final DateTime createdAt;
final DateTime updatedAt;
+ final DateTime? lastReadAt;
const ShopInBitTicket({
required this.apiTicketId,
required this.customerKey,
@@ -1228,6 +1249,7 @@ class ShopInBitTicket extends DataClass implements Insertable<ShopInBitTicket> {
required this.messages,
required this.createdAt,
required this.updatedAt,
+ this.lastReadAt,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
@@ -1285,6 +1307,11 @@ class ShopInBitTicket extends DataClass implements Insertable<ShopInBitTicket> {
$ShopInBitTicketsTable.$converterupdatedAt.toSql(updatedAt),
);
}
+ if (!nullToAbsent || lastReadAt != null) {
+ map['last_read_at'] = Variable<String>(
+ $ShopInBitTicketsTable.$converterlastReadAtn.toSql(lastReadAt),
+ );
+ }
return map;
}
@@ -1319,6 +1346,9 @@ class ShopInBitTicket extends DataClass implements Insertable<ShopInBitTicket> {
messages: Value(messages),
createdAt: Value(createdAt),
updatedAt: Value(updatedAt),
+ lastReadAt: lastReadAt == null && nullToAbsent
+ ? const Value.absent()
+ : Value(lastReadAt),
);
}
@@ -1355,6 +1385,7 @@ class ShopInBitTicket extends DataClass implements Insertable<ShopInBitTicket> {
messages: serializer.fromJson<List<TicketMessage>>(json['messages']),
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
updatedAt: serializer.fromJson<DateTime>(json['updatedAt']),
+ lastReadAt: serializer.fromJson<DateTime?>(json['lastReadAt']),
);
}
@override
@@ -1382,6 +1413,7 @@ class ShopInBitTicket extends DataClass implements Insertable<ShopInBitTicket> {
'messages': serializer.toJson<List<TicketMessage>>(messages),
'createdAt': serializer.toJson<DateTime>(createdAt),
'updatedAt': serializer.toJson<DateTime>(updatedAt),
+ 'lastReadAt': serializer.toJson<DateTime?>(lastReadAt),
};
}
@@ -1403,6 +1435,7 @@ class ShopInBitTicket extends DataClass implements Insertable<ShopInBitTicket> {
List<TicketMessage>? messages,
DateTime? createdAt,
DateTime? updatedAt,
+ Value<DateTime?> lastReadAt = const Value.absent(),
}) => ShopInBitTicket(
apiTicketId: apiTicketId ?? this.apiTicketId,
customerKey: customerKey ?? this.customerKey,
@@ -1429,6 +1462,7 @@ class ShopInBitTicket extends DataClass implements Insertable<ShopInBitTicket> {
messages: messages ?? this.messages,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
+ lastReadAt: lastReadAt.present ? lastReadAt.value : this.lastReadAt,
);
ShopInBitTicket copyWithCompanion(ShopInBitTicketsCompanion data) {
return ShopInBitTicket(
@@ -1471,6 +1505,9 @@ class ShopInBitTicket extends DataClass implements Insertable<ShopInBitTicket> {
messages: data.messages.present ? data.messages.value : this.messages,
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt,
+ lastReadAt: data.lastReadAt.present
+ ? data.lastReadAt.value
+ : this.lastReadAt,
);
}
@@ -1493,7 +1530,8 @@ class ShopInBitTicket extends DataClass implements Insertable<ShopInBitTicket> {
..write('feeTicketNumber: $feeTicketNumber, ')
..write('messages: $messages, ')
..write('createdAt: $createdAt, ')
- ..write('updatedAt: $updatedAt')
+ ..write('updatedAt: $updatedAt, ')
+ ..write('lastReadAt: $lastReadAt')
..write(')'))
.toString();
}
@@ -1517,6 +1555,7 @@ class ShopInBitTicket extends DataClass implements Insertable<ShopInBitTicket> {
messages,
createdAt,
updatedAt,
+ lastReadAt,
);
@override
bool operator ==(Object other) =>
@@ -1538,7 +1577,8 @@ class ShopInBitTicket extends DataClass implements Insertable<ShopInBitTicket> {
other.feeTicketNumber == this.feeTicketNumber &&
other.messages == this.messages &&
other.createdAt == this.createdAt &&
- other.updatedAt == this.updatedAt);
+ other.updatedAt == this.updatedAt &&
+ other.lastReadAt == this.lastReadAt);
}
class ShopInBitTicketsCompanion extends UpdateCompanion<ShopInBitTicket> {
@@ -1559,6 +1599,7 @@ class ShopInBitTicketsCompanion extends UpdateCompanion<ShopInBitTicket> {
final Value<List<TicketMessage>> messages;
final Value<DateTime> createdAt;
final Value<DateTime> updatedAt;
+ final Value<DateTime?> lastReadAt;
const ShopInBitTicketsCompanion({
this.apiTicketId = const Value.absent(),
this.customerKey = const Value.absent(),
@@ -1577,6 +1618,7 @@ class ShopInBitTicketsCompanion extends UpdateCompanion<ShopInBitTicket> {
this.messages = const Value.absent(),
this.createdAt = const Value.absent(),
this.updatedAt = const Value.absent(),
+ this.lastReadAt = const Value.absent(),
});
ShopInBitTicketsCompanion.insert({
required int apiTicketId,
@@ -1596,6 +1638,7 @@ class ShopInBitTicketsCompanion extends UpdateCompanion<ShopInBitTicket> {
this.messages = const Value.absent(),
this.createdAt = const Value.absent(),
this.updatedAt = const Value.absent(),
+ this.lastReadAt = const Value.absent(),
}) : apiTicketId = Value(apiTicketId),
customerKey = Value(customerKey),
ticketNumber = Value(ticketNumber),
@@ -1622,6 +1665,7 @@ class ShopInBitTicketsCompanion extends UpdateCompanion<ShopInBitTicket> {
Expression<String>? messages,
Expression<String>? createdAt,
Expression<String>? updatedAt,
+ Expression<String>? lastReadAt,
}) {
return RawValuesInsertable({
if (apiTicketId != null) 'api_ticket_id': apiTicketId,
@@ -1643,6 +1687,7 @@ class ShopInBitTicketsCompanion extends UpdateCompanion<ShopInBitTicket> {
if (messages != null) 'messages': messages,
if (createdAt != null) 'created_at': createdAt,
if (updatedAt != null) 'updated_at': updatedAt,
+ if (lastReadAt != null) 'last_read_at': lastReadAt,
});
}
@@ -1664,6 +1709,7 @@ class ShopInBitTicketsCompanion extends UpdateCompanion<ShopInBitTicket> {
Value<List<TicketMessage>>? messages,
Value<DateTime>? createdAt,
Value<DateTime>? updatedAt,
+ Value<DateTime?>? lastReadAt,
}) {
return ShopInBitTicketsCompanion(
apiTicketId: apiTicketId ?? this.apiTicketId,
@@ -1683,6 +1729,7 @@ class ShopInBitTicketsCompanion extends UpdateCompanion<ShopInBitTicket> {
messages: messages ?? this.messages,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
+ lastReadAt: lastReadAt ?? this.lastReadAt,
);
}
@@ -1756,6 +1803,11 @@ class ShopInBitTicketsCompanion extends UpdateCompanion<ShopInBitTicket> {
$ShopInBitTicketsTable.$converterupdatedAt.toSql(updatedAt.value),
);
}
+ if (lastReadAt.present) {
+ map['last_read_at'] = Variable<String>(
+ $ShopInBitTicketsTable.$converterlastReadAtn.toSql(lastReadAt.value),
+ );
+ }
return map;
}
@@ -1778,7 +1830,562 @@ class ShopInBitTicketsCompanion extends UpdateCompanion<ShopInBitTicket> {
..write('feeTicketNumber: $feeTicketNumber, ')
..write('messages: $messages, ')
..write('createdAt: $createdAt, ')
- ..write('updatedAt: $updatedAt')
+ ..write('updatedAt: $updatedAt, ')
+ ..write('lastReadAt: $lastReadAt')
+ ..write(')'))
+ .toString();
+ }
+}
+
+class $AppNotificationsTable extends AppNotifications
+ with TableInfo<$AppNotificationsTable, AppNotification> {
+ @override
+ final GeneratedDatabase attachedDatabase;
+ final String? _alias;
+ $AppNotificationsTable(this.attachedDatabase, [this._alias]);
+ static const VerificationMeta _idMeta = const VerificationMeta('id');
+ @override
+ late final GeneratedColumn<int> id = GeneratedColumn<int>(
+ 'id',
+ aliasedName,
+ false,
+ hasAutoIncrement: true,
+ type: DriftSqlType.int,
+ requiredDuringInsert: false,
+ defaultConstraints: GeneratedColumn.constraintIsAlways(
+ 'PRIMARY KEY AUTOINCREMENT',
+ ),
+ );
+ @override
+ late final GeneratedColumnWithTypeConverter<AppNotificationType, String>
+ type = GeneratedColumn<String>(
+ 'type',
+ aliasedName,
+ false,
+ type: DriftSqlType.string,
+ requiredDuringInsert: true,
+ ).withConverter<AppNotificationType>($AppNotificationsTable.$convertertype);
+ static const VerificationMeta _titleMeta = const VerificationMeta('title');
+ @override
+ late final GeneratedColumn<String> title = GeneratedColumn<String>(
+ 'title',
+ aliasedName,
+ false,
+ type: DriftSqlType.string,
+ requiredDuringInsert: true,
+ );
+ static const VerificationMeta _bodyMeta = const VerificationMeta('body');
+ @override
+ late final GeneratedColumn<String> body = GeneratedColumn<String>(
+ 'body',
+ aliasedName,
+ false,
+ type: DriftSqlType.string,
+ requiredDuringInsert: false,
+ defaultValue: const Constant(""),
+ );
+ static const VerificationMeta _iconAssetMeta = const VerificationMeta(
+ 'iconAsset',
+ );
+ @override
+ late final GeneratedColumn<String> iconAsset = GeneratedColumn<String>(
+ 'icon_asset',
+ aliasedName,
+ true,
+ type: DriftSqlType.string,
+ requiredDuringInsert: false,
+ );
+ @override
+ late final GeneratedColumnWithTypeConverter<DateTime, String> createdAt =
+ GeneratedColumn<String>(
+ 'created_at',
+ aliasedName,
+ false,
+ type: DriftSqlType.string,
+ requiredDuringInsert: false,
+ clientDefault: () =>
+ ShopInBitTickets.dateConverter.toSql(DateTime.now()),
+ ).withConverter<DateTime>($AppNotificationsTable.$convertercreatedAt);
+ static const VerificationMeta _readMeta = const VerificationMeta('read');
+ @override
+ late final GeneratedColumn<bool> read = GeneratedColumn<bool>(
+ 'read',
+ aliasedName,
+ false,
+ type: DriftSqlType.bool,
+ requiredDuringInsert: false,
+ defaultConstraints: GeneratedColumn.constraintIsAlways(
+ 'CHECK ("read" IN (0, 1))',
+ ),
+ defaultValue: const Constant(false),
+ );
+ static const VerificationMeta _scopeIdMeta = const VerificationMeta(
+ 'scopeId',
+ );
+ @override
+ late final GeneratedColumn<String> scopeId = GeneratedColumn<String>(
+ 'scope_id',
+ aliasedName,
+ true,
+ type: DriftSqlType.string,
+ requiredDuringInsert: false,
+ );
+ static const VerificationMeta _targetIdMeta = const VerificationMeta(
+ 'targetId',
+ );
+ @override
+ late final GeneratedColumn<String> targetId = GeneratedColumn<String>(
+ 'target_id',
+ aliasedName,
+ true,
+ type: DriftSqlType.string,
+ requiredDuringInsert: false,
+ );
+ @override
+ List<GeneratedColumn> get $columns => [
+ id,
+ type,
+ title,
+ body,
+ iconAsset,
+ createdAt,
+ read,
+ scopeId,
+ targetId,
+ ];
+ @override
+ String get aliasedName => _alias ?? actualTableName;
+ @override
+ String get actualTableName => $name;
+ static const String $name = 'app_notifications';
+ @override
+ VerificationContext validateIntegrity(
+ Insertable<AppNotification> instance, {
+ bool isInserting = false,
+ }) {
+ final context = VerificationContext();
+ final data = instance.toColumns(true);
+ if (data.containsKey('id')) {
+ context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
+ }
+ if (data.containsKey('title')) {
+ context.handle(
+ _titleMeta,
+ title.isAcceptableOrUnknown(data['title']!, _titleMeta),
+ );
+ } else if (isInserting) {
+ context.missing(_titleMeta);
+ }
+ if (data.containsKey('body')) {
+ context.handle(
+ _bodyMeta,
+ body.isAcceptableOrUnknown(data['body']!, _bodyMeta),
+ );
+ }
+ if (data.containsKey('icon_asset')) {
+ context.handle(
+ _iconAssetMeta,
+ iconAsset.isAcceptableOrUnknown(data['icon_asset']!, _iconAssetMeta),
+ );
+ }
+ if (data.containsKey('read')) {
+ context.handle(
+ _readMeta,
+ read.isAcceptableOrUnknown(data['read']!, _readMeta),
+ );
+ }
+ if (data.containsKey('scope_id')) {
+ context.handle(
+ _scopeIdMeta,
+ scopeId.isAcceptableOrUnknown(data['scope_id']!, _scopeIdMeta),
+ );
+ }
+ if (data.containsKey('target_id')) {
+ context.handle(
+ _targetIdMeta,
+ targetId.isAcceptableOrUnknown(data['target_id']!, _targetIdMeta),
+ );
+ }
+ return context;
+ }
+
+ @override
+ Set<GeneratedColumn> get $primaryKey => {id};
+ @override
+ AppNotification map(Map<String, dynamic> data, {String? tablePrefix}) {
+ final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
+ return AppNotification(
+ id: attachedDatabase.typeMapping.read(
+ DriftSqlType.int,
+ data['${effectivePrefix}id'],
+ )!,
+ type: $AppNotificationsTable.$convertertype.fromSql(
+ attachedDatabase.typeMapping.read(
+ DriftSqlType.string,
+ data['${effectivePrefix}type'],
+ )!,
+ ),
+ title: attachedDatabase.typeMapping.read(
+ DriftSqlType.string,
+ data['${effectivePrefix}title'],
+ )!,
+ body: attachedDatabase.typeMapping.read(
+ DriftSqlType.string,
+ data['${effectivePrefix}body'],
+ )!,
+ iconAsset: attachedDatabase.typeMapping.read(
+ DriftSqlType.string,
+ data['${effectivePrefix}icon_asset'],
+ ),
+ createdAt: $AppNotificationsTable.$convertercreatedAt.fromSql(
+ attachedDatabase.typeMapping.read(
+ DriftSqlType.string,
+ data['${effectivePrefix}created_at'],
+ )!,
+ ),
+ read: attachedDatabase.typeMapping.read(
+ DriftSqlType.bool,
+ data['${effectivePrefix}read'],
+ )!,
+ scopeId: attachedDatabase.typeMapping.read(
+ DriftSqlType.string,
+ data['${effectivePrefix}scope_id'],
+ ),
+ targetId: attachedDatabase.typeMapping.read(
+ DriftSqlType.string,
+ data['${effectivePrefix}target_id'],
+ ),
+ );
+ }
+
+ @override
+ $AppNotificationsTable createAlias(String alias) {
+ return $AppNotificationsTable(attachedDatabase, alias);
+ }
+
+ static JsonTypeConverter2<AppNotificationType, String, String>
+ $convertertype = const EnumNameConverter<AppNotificationType>(
+ AppNotificationType.values,
+ );
+ static TypeConverter<DateTime, String> $convertercreatedAt =
+ ShopInBitTickets.dateConverter;
+}
+
+class AppNotification extends DataClass implements Insertable<AppNotification> {
+ final int id;
+ final AppNotificationType type;
+ final String title;
+ final String body;
+ final String? iconAsset;
+ final DateTime createdAt;
+ final bool read;
+ final String? scopeId;
+ final String? targetId;
+ const AppNotification({
+ required this.id,
+ required this.type,
+ required this.title,
+ required this.body,
+ this.iconAsset,
+ required this.createdAt,
+ required this.read,
+ this.scopeId,
+ this.targetId,
+ });
+ @override
+ Map<String, Expression> toColumns(bool nullToAbsent) {
+ final map = <String, Expression>{};
+ map['id'] = Variable<int>(id);
+ {
+ map['type'] = Variable<String>(
+ $AppNotificationsTable.$convertertype.toSql(type),
+ );
+ }
+ map['title'] = Variable<String>(title);
+ map['body'] = Variable<String>(body);
+ if (!nullToAbsent || iconAsset != null) {
+ map['icon_asset'] = Variable<String>(iconAsset);
+ }
+ {
+ map['created_at'] = Variable<String>(
+ $AppNotificationsTable.$convertercreatedAt.toSql(createdAt),
+ );
+ }
+ map['read'] = Variable<bool>(read);
+ if (!nullToAbsent || scopeId != null) {
+ map['scope_id'] = Variable<String>(scopeId);
+ }
+ if (!nullToAbsent || targetId != null) {
+ map['target_id'] = Variable<String>(targetId);
+ }
+ return map;
+ }
+
+ AppNotificationsCompanion toCompanion(bool nullToAbsent) {
+ return AppNotificationsCompanion(
+ id: Value(id),
+ type: Value(type),
+ title: Value(title),
+ body: Value(body),
+ iconAsset: iconAsset == null && nullToAbsent
+ ? const Value.absent()
+ : Value(iconAsset),
+ createdAt: Value(createdAt),
+ read: Value(read),
+ scopeId: scopeId == null && nullToAbsent
+ ? const Value.absent()
+ : Value(scopeId),
+ targetId: targetId == null && nullToAbsent
+ ? const Value.absent()
+ : Value(targetId),
+ );
+ }
+
+ factory AppNotification.fromJson(
+ Map<String, dynamic> json, {
+ ValueSerializer? serializer,
+ }) {
+ serializer ??= driftRuntimeOptions.defaultSerializer;
+ return AppNotification(
+ id: serializer.fromJson<int>(json['id']),
+ type: $AppNotificationsTable.$convertertype.fromJson(
+ serializer.fromJson<String>(json['type']),
+ ),
+ title: serializer.fromJson<String>(json['title']),
+ body: serializer.fromJson<String>(json['body']),
+ iconAsset: serializer.fromJson<String?>(json['iconAsset']),
+ createdAt: serializer.fromJson<DateTime>(json['createdAt']),
+ read: serializer.fromJson<bool>(json['read']),
+ scopeId: serializer.fromJson<String?>(json['scopeId']),
+ targetId: serializer.fromJson<String?>(json['targetId']),
+ );
+ }
+ @override
+ Map<String, dynamic> toJson({ValueSerializer? serializer}) {
+ serializer ??= driftRuntimeOptions.defaultSerializer;
+ return <String, dynamic>{
+ 'id': serializer.toJson<int>(id),
+ 'type': serializer.toJson<String>(
+ $AppNotificationsTable.$convertertype.toJson(type),
+ ),
+ 'title': serializer.toJson<String>(title),
+ 'body': serializer.toJson<String>(body),
+ 'iconAsset': serializer.toJson<String?>(iconAsset),
+ 'createdAt': serializer.toJson<DateTime>(createdAt),
+ 'read': serializer.toJson<bool>(read),
+ 'scopeId': serializer.toJson<String?>(scopeId),
+ 'targetId': serializer.toJson<String?>(targetId),
+ };
+ }
+
+ AppNotification copyWith({
+ int? id,
+ AppNotificationType? type,
+ String? title,
+ String? body,
+ Value<String?> iconAsset = const Value.absent(),
+ DateTime? createdAt,
+ bool? read,
+ Value<String?> scopeId = const Value.absent(),
+ Value<String?> targetId = const Value.absent(),
+ }) => AppNotification(
+ id: id ?? this.id,
+ type: type ?? this.type,
+ title: title ?? this.title,
+ body: body ?? this.body,
+ iconAsset: iconAsset.present ? iconAsset.value : this.iconAsset,
+ createdAt: createdAt ?? this.createdAt,
+ read: read ?? this.read,
+ scopeId: scopeId.present ? scopeId.value : this.scopeId,
+ targetId: targetId.present ? targetId.value : this.targetId,
+ );
+ AppNotification copyWithCompanion(AppNotificationsCompanion data) {
+ return AppNotification(
+ id: data.id.present ? data.id.value : this.id,
+ type: data.type.present ? data.type.value : this.type,
+ title: data.title.present ? data.title.value : this.title,
+ body: data.body.present ? data.body.value : this.body,
+ iconAsset: data.iconAsset.present ? data.iconAsset.value : this.iconAsset,
+ createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
+ read: data.read.present ? data.read.value : this.read,
+ scopeId: data.scopeId.present ? data.scopeId.value : this.scopeId,
+ targetId: data.targetId.present ? data.targetId.value : this.targetId,
+ );
+ }
+
+ @override
+ String toString() {
+ return (StringBuffer('AppNotification(')
+ ..write('id: $id, ')
+ ..write('type: $type, ')
+ ..write('title: $title, ')
+ ..write('body: $body, ')
+ ..write('iconAsset: $iconAsset, ')
+ ..write('createdAt: $createdAt, ')
+ ..write('read: $read, ')
+ ..write('scopeId: $scopeId, ')
+ ..write('targetId: $targetId')
+ ..write(')'))
+ .toString();
+ }
+
+ @override
+ int get hashCode => Object.hash(
+ id,
+ type,
+ title,
+ body,
+ iconAsset,
+ createdAt,
+ read,
+ scopeId,
+ targetId,
+ );
+ @override
+ bool operator ==(Object other) =>
+ identical(this, other) ||
+ (other is AppNotification &&
+ other.id == this.id &&
+ other.type == this.type &&
+ other.title == this.title &&
+ other.body == this.body &&
+ other.iconAsset == this.iconAsset &&
+ other.createdAt == this.createdAt &&
+ other.read == this.read &&
+ other.scopeId == this.scopeId &&
+ other.targetId == this.targetId);
+}
+
+class AppNotificationsCompanion extends UpdateCompanion<AppNotification> {
+ final Value<int> id;
+ final Value<AppNotificationType> type;
+ final Value<String> title;
+ final Value<String> body;
+ final Value<String?> iconAsset;
+ final Value<DateTime> createdAt;
+ final Value<bool> read;
+ final Value<String?> scopeId;
+ final Value<String?> targetId;
+ const AppNotificationsCompanion({
+ this.id = const Value.absent(),
+ this.type = const Value.absent(),
+ this.title = const Value.absent(),
+ this.body = const Value.absent(),
+ this.iconAsset = const Value.absent(),
+ this.createdAt = const Value.absent(),
+ this.read = const Value.absent(),
+ this.scopeId = const Value.absent(),
+ this.targetId = const Value.absent(),
+ });
+ AppNotificationsCompanion.insert({
+ this.id = const Value.absent(),
+ required AppNotificationType type,
+ required String title,
+ this.body = const Value.absent(),
+ this.iconAsset = const Value.absent(),
+ this.createdAt = const Value.absent(),
+ this.read = const Value.absent(),
+ this.scopeId = const Value.absent(),
+ this.targetId = const Value.absent(),
+ }) : type = Value(type),
+ title = Value(title);
+ static Insertable<AppNotification> custom({
+ Expression<int>? id,
+ Expression<String>? type,
+ Expression<String>? title,
+ Expression<String>? body,
+ Expression<String>? iconAsset,
+ Expression<String>? createdAt,
+ Expression<bool>? read,
+ Expression<String>? scopeId,
+ Expression<String>? targetId,
+ }) {
+ return RawValuesInsertable({
+ if (id != null) 'id': id,
+ if (type != null) 'type': type,
+ if (title != null) 'title': title,
+ if (body != null) 'body': body,
+ if (iconAsset != null) 'icon_asset': iconAsset,
+ if (createdAt != null) 'created_at': createdAt,
+ if (read != null) 'read': read,
+ if (scopeId != null) 'scope_id': scopeId,
+ if (targetId != null) 'target_id': targetId,
+ });
+ }
+
+ AppNotificationsCompanion copyWith({
+ Value<int>? id,
+ Value<AppNotificationType>? type,
+ Value<String>? title,
+ Value<String>? body,
+ Value<String?>? iconAsset,
+ Value<DateTime>? createdAt,
+ Value<bool>? read,
+ Value<String?>? scopeId,
+ Value<String?>? targetId,
+ }) {
+ return AppNotificationsCompanion(
+ id: id ?? this.id,
+ type: type ?? this.type,
+ title: title ?? this.title,
+ body: body ?? this.body,
+ iconAsset: iconAsset ?? this.iconAsset,
+ createdAt: createdAt ?? this.createdAt,
+ read: read ?? this.read,
+ scopeId: scopeId ?? this.scopeId,
+ targetId: targetId ?? this.targetId,
+ );
+ }
+
+ @override
+ Map<String, Expression> toColumns(bool nullToAbsent) {
+ final map = <String, Expression>{};
+ if (id.present) {
+ map['id'] = Variable<int>(id.value);
+ }
+ if (type.present) {
+ map['type'] = Variable<String>(
+ $AppNotificationsTable.$convertertype.toSql(type.value),
+ );
+ }
+ if (title.present) {
+ map['title'] = Variable<String>(title.value);
+ }
+ if (body.present) {
+ map['body'] = Variable<String>(body.value);
+ }
+ if (iconAsset.present) {
+ map['icon_asset'] = Variable<String>(iconAsset.value);
+ }
+ if (createdAt.present) {
+ map['created_at'] = Variable<String>(
+ $AppNotificationsTable.$convertercreatedAt.toSql(createdAt.value),
+ );
+ }
+ if (read.present) {
+ map['read'] = Variable<bool>(read.value);
+ }
+ if (scopeId.present) {
+ map['scope_id'] = Variable<String>(scopeId.value);
+ }
+ if (targetId.present) {
+ map['target_id'] = Variable<String>(targetId.value);
+ }
+ return map;
+ }
+
+ @override
+ String toString() {
+ return (StringBuffer('AppNotificationsCompanion(')
+ ..write('id: $id, ')
+ ..write('type: $type, ')
+ ..write('title: $title, ')
+ ..write('body: $body, ')
+ ..write('iconAsset: $iconAsset, ')
+ ..write('createdAt: $createdAt, ')
+ ..write('read: $read, ')
+ ..write('scopeId: $scopeId, ')
+ ..write('targetId: $targetId')
..write(')'))
.toString();
}
@@ -1793,12 +2400,26 @@ abstract class _$SharedDatabase extends GeneratedDatabase {
late final $ShopInBitTicketsTable shopInBitTickets = $ShopInBitTicketsTable(
this,
);
+ late final $AppNotificationsTable appNotifications = $AppNotificationsTable(
+ this,
+ );
+ late final Index appNotificationsScope = Index(
+ 'app_notifications_scope',
+ 'CREATE INDEX app_notifications_scope ON app_notifications (type, scope_id, read)',
+ );
+ late final Index appNotificationsTarget = Index(
+ 'app_notifications_target',
+ 'CREATE INDEX app_notifications_target ON app_notifications (type, target_id)',
+ );
late final ShopInBitSettingsDao shopInBitSettingsDao = ShopInBitSettingsDao(
this as SharedDatabase,
);
late final ShopInBitTicketsDao shopInBitTicketsDao = ShopInBitTicketsDao(
this as SharedDatabase,
);
+ late final AppNotificationsDao appNotificationsDao = AppNotificationsDao(
+ this as SharedDatabase,
+ );
@override
Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@@ -1807,6 +2428,9 @@ abstract class _$SharedDatabase extends GeneratedDatabase {
cakepayOrders,
shopInBitSettings,
shopInBitTickets,
+ appNotifications,
+ appNotificationsScope,
+ appNotificationsTarget,
];
}
@@ -2225,6 +2849,7 @@ typedef $$ShopInBitTicketsTableCreateCompanionBuilder =
Value<List<TicketMessage>> messages,
Value<DateTime> createdAt,
Value<DateTime> updatedAt,
+ Value<DateTime?> lastReadAt,
});
typedef $$ShopInBitTicketsTableUpdateCompanionBuilder =
ShopInBitTicketsCompanion Function({
@@ -2245,6 +2870,7 @@ typedef $$ShopInBitTicketsTableUpdateCompanionBuilder =
Value<List<TicketMessage>> messages,
Value<DateTime> createdAt,
Value<DateTime> updatedAt,
+ Value<DateTime?> lastReadAt,
});
class $$ShopInBitTicketsTableFilterComposer
@@ -2354,6 +2980,12 @@ class $$ShopInBitTicketsTableFilterComposer
column: $table.updatedAt,
builder: (column) => ColumnWithTypeConverterFilters(column),
);
+
+ ColumnWithTypeConverterFilters<DateTime?, DateTime, String> get lastReadAt =>
+ $composableBuilder(
+ column: $table.lastReadAt,
+ builder: (column) => ColumnWithTypeConverterFilters(column),
+ );
}
class $$ShopInBitTicketsTableOrderingComposer
@@ -2449,6 +3081,11 @@ class $$ShopInBitTicketsTableOrderingComposer
column: $table.updatedAt,
builder: (column) => ColumnOrderings(column),
);
+
+ ColumnOrderings<String> get lastReadAt => $composableBuilder(
+ column: $table.lastReadAt,
+ builder: (column) => ColumnOrderings(column),
+ );
}
class $$ShopInBitTicketsTableAnnotationComposer
@@ -2533,6 +3170,12 @@ class $$ShopInBitTicketsTableAnnotationComposer
GeneratedColumnWithTypeConverter<DateTime, String> get updatedAt =>
$composableBuilder(column: $table.updatedAt, builder: (column) => column);
+
+ GeneratedColumnWithTypeConverter<DateTime?, String> get lastReadAt =>
+ $composableBuilder(
+ column: $table.lastReadAt,
+ builder: (column) => column,
+ );
}
class $$ShopInBitTicketsTableTableManager
@@ -2589,6 +3232,7 @@ class $$ShopInBitTicketsTableTableManager
Value<List<TicketMessage>> messages = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(),
Value<DateTime> updatedAt = const Value.absent(),
+ Value<DateTime?> lastReadAt = const Value.absent(),
}) => ShopInBitTicketsCompanion(
apiTicketId: apiTicketId,
customerKey: customerKey,
@@ -2607,6 +3251,7 @@ class $$ShopInBitTicketsTableTableManager
messages: messages,
createdAt: createdAt,
updatedAt: updatedAt,
+ lastReadAt: lastReadAt,
),
createCompanionCallback:
({
@@ -2627,6 +3272,7 @@ class $$ShopInBitTicketsTableTableManager
Value<List<TicketMessage>> messages = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(),
Value<DateTime> updatedAt = const Value.absent(),
+ Value<DateTime?> lastReadAt = const Value.absent(),
}) => ShopInBitTicketsCompanion.insert(
apiTicketId: apiTicketId,
customerKey: customerKey,
@@ -2645,6 +3291,7 @@ class $$ShopInBitTicketsTableTableManager
messages: messages,
createdAt: createdAt,
updatedAt: updatedAt,
+ lastReadAt: lastReadAt,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
@@ -2675,6 +3322,292 @@ typedef $$ShopInBitTicketsTableProcessedTableManager =
ShopInBitTicket,
PrefetchHooks Function()
>;
+typedef $$AppNotificationsTableCreateCompanionBuilder =
+ AppNotificationsCompanion Function({
+ Value<int> id,
+ required AppNotificationType type,
+ required String title,
+ Value<String> body,
+ Value<String?> iconAsset,
+ Value<DateTime> createdAt,
+ Value<bool> read,
+ Value<String?> scopeId,
+ Value<String?> targetId,
+ });
+typedef $$AppNotificationsTableUpdateCompanionBuilder =
+ AppNotificationsCompanion Function({
+ Value<int> id,
+ Value<AppNotificationType> type,
+ Value<String> title,
+ Value<String> body,
+ Value<String?> iconAsset,
+ Value<DateTime> createdAt,
+ Value<bool> read,
+ Value<String?> scopeId,
+ Value<String?> targetId,
+ });
+
+class $$AppNotificationsTableFilterComposer
+ extends Composer<_$SharedDatabase, $AppNotificationsTable> {
+ $$AppNotificationsTableFilterComposer({
+ required super.$db,
+ required super.$table,
+ super.joinBuilder,
+ super.$addJoinBuilderToRootComposer,
+ super.$removeJoinBuilderFromRootComposer,
+ });
+ ColumnFilters<int> get id => $composableBuilder(
+ column: $table.id,
+ builder: (column) => ColumnFilters(column),
+ );
+
+ ColumnWithTypeConverterFilters<
+ AppNotificationType,
+ AppNotificationType,
+ String
+ >
+ get type => $composableBuilder(
+ column: $table.type,
+ builder: (column) => ColumnWithTypeConverterFilters(column),
+ );
+
+ ColumnFilters<String> get title => $composableBuilder(
+ column: $table.title,
+ builder: (column) => ColumnFilters(column),
+ );
+
+ ColumnFilters<String> get body => $composableBuilder(
+ column: $table.body,
+ builder: (column) => ColumnFilters(column),
+ );
+
+ ColumnFilters<String> get iconAsset => $composableBuilder(
+ column: $table.iconAsset,
+ builder: (column) => ColumnFilters(column),
+ );
+
+ ColumnWithTypeConverterFilters<DateTime, DateTime, String> get createdAt =>
+ $composableBuilder(
+ column: $table.createdAt,
+ builder: (column) => ColumnWithTypeConverterFilters(column),
+ );
+
+ ColumnFilters<bool> get read => $composableBuilder(
+ column: $table.read,
+ builder: (column) => ColumnFilters(column),
+ );
+
+ ColumnFilters<String> get scopeId => $composableBuilder(
+ column: $table.scopeId,
+ builder: (column) => ColumnFilters(column),
+ );
+
+ ColumnFilters<String> get targetId => $composableBuilder(
+ column: $table.targetId,
+ builder: (column) => ColumnFilters(column),
+ );
+}
+
+class $$AppNotificationsTableOrderingComposer
+ extends Composer<_$SharedDatabase, $AppNotificationsTable> {
+ $$AppNotificationsTableOrderingComposer({
+ required super.$db,
+ required super.$table,
+ super.joinBuilder,
+ super.$addJoinBuilderToRootComposer,
+ super.$removeJoinBuilderFromRootComposer,
+ });
+ ColumnOrderings<int> get id => $composableBuilder(
+ column: $table.id,
+ builder: (column) => ColumnOrderings(column),
+ );
+
+ ColumnOrderings<String> get type => $composableBuilder(
+ column: $table.type,
+ builder: (column) => ColumnOrderings(column),
+ );
+
+ ColumnOrderings<String> get title => $composableBuilder(
+ column: $table.title,
+ builder: (column) => ColumnOrderings(column),
+ );
+
+ ColumnOrderings<String> get body => $composableBuilder(
+ column: $table.body,
+ builder: (column) => ColumnOrderings(column),
+ );
+
+ ColumnOrderings<String> get iconAsset => $composableBuilder(
+ column: $table.iconAsset,
+ builder: (column) => ColumnOrderings(column),
+ );
+
+ ColumnOrderings<String> get createdAt => $composableBuilder(
+ column: $table.createdAt,
+ builder: (column) => ColumnOrderings(column),
+ );
+
+ ColumnOrderings<bool> get read => $composableBuilder(
+ column: $table.read,
+ builder: (column) => ColumnOrderings(column),
+ );
+
+ ColumnOrderings<String> get scopeId => $composableBuilder(
+ column: $table.scopeId,
+ builder: (column) => ColumnOrderings(column),
+ );
+
+ ColumnOrderings<String> get targetId => $composableBuilder(
+ column: $table.targetId,
+ builder: (column) => ColumnOrderings(column),
+ );
+}
+
+class $$AppNotificationsTableAnnotationComposer
+ extends Composer<_$SharedDatabase, $AppNotificationsTable> {
+ $$AppNotificationsTableAnnotationComposer({
+ required super.$db,
+ required super.$table,
+ super.joinBuilder,
+ super.$addJoinBuilderToRootComposer,
+ super.$removeJoinBuilderFromRootComposer,
+ });
+ GeneratedColumn<int> get id =>
+ $composableBuilder(column: $table.id, builder: (column) => column);
+
+ GeneratedColumnWithTypeConverter<AppNotificationType, String> get type =>
+ $composableBuilder(column: $table.type, builder: (column) => column);
+
+ GeneratedColumn<String> get title =>
+ $composableBuilder(column: $table.title, builder: (column) => column);
+
+ GeneratedColumn<String> get body =>
+ $composableBuilder(column: $table.body, builder: (column) => column);
+
+ GeneratedColumn<String> get iconAsset =>
+ $composableBuilder(column: $table.iconAsset, builder: (column) => column);
+
+ GeneratedColumnWithTypeConverter<DateTime, String> get createdAt =>
+ $composableBuilder(column: $table.createdAt, builder: (column) => column);
+
+ GeneratedColumn<bool> get read =>
+ $composableBuilder(column: $table.read, builder: (column) => column);
+
+ GeneratedColumn<String> get scopeId =>
+ $composableBuilder(column: $table.scopeId, builder: (column) => column);
+
+ GeneratedColumn<String> get targetId =>
+ $composableBuilder(column: $table.targetId, builder: (column) => column);
+}
+
+class $$AppNotificationsTableTableManager
+ extends
+ RootTableManager<
+ _$SharedDatabase,
+ $AppNotificationsTable,
+ AppNotification,
+ $$AppNotificationsTableFilterComposer,
+ $$AppNotificationsTableOrderingComposer,
+ $$AppNotificationsTableAnnotationComposer,
+ $$AppNotificationsTableCreateCompanionBuilder,
+ $$AppNotificationsTableUpdateCompanionBuilder,
+ (
+ AppNotification,
+ BaseReferences<
+ _$SharedDatabase,
+ $AppNotificationsTable,
+ AppNotification
+ >,
+ ),
+ AppNotification,
+ PrefetchHooks Function()
+ > {
+ $$AppNotificationsTableTableManager(
+ _$SharedDatabase db,
+ $AppNotificationsTable table,
+ ) : super(
+ TableManagerState(
+ db: db,
+ table: table,
+ createFilteringComposer: () =>
+ $$AppNotificationsTableFilterComposer($db: db, $table: table),
+ createOrderingComposer: () =>
+ $$AppNotificationsTableOrderingComposer($db: db, $table: table),
+ createComputedFieldComposer: () =>
+ $$AppNotificationsTableAnnotationComposer($db: db, $table: table),
+ updateCompanionCallback:
+ ({
+ Value<int> id = const Value.absent(),
+ Value<AppNotificationType> type = const Value.absent(),
+ Value<String> title = const Value.absent(),
+ Value<String> body = const Value.absent(),
+ Value<String?> iconAsset = const Value.absent(),
+ Value<DateTime> createdAt = const Value.absent(),
+ Value<bool> read = const Value.absent(),
+ Value<String?> scopeId = const Value.absent(),
+ Value<String?> targetId = const Value.absent(),
+ }) => AppNotificationsCompanion(
+ id: id,
+ type: type,
+ title: title,
+ body: body,
+ iconAsset: iconAsset,
+ createdAt: createdAt,
+ read: read,
+ scopeId: scopeId,
+ targetId: targetId,
+ ),
+ createCompanionCallback:
+ ({
+ Value<int> id = const Value.absent(),
+ required AppNotificationType type,
+ required String title,
+ Value<String> body = const Value.absent(),
+ Value<String?> iconAsset = const Value.absent(),
+ Value<DateTime> createdAt = const Value.absent(),
+ Value<bool> read = const Value.absent(),
+ Value<String?> scopeId = const Value.absent(),
+ Value<String?> targetId = const Value.absent(),
+ }) => AppNotificationsCompanion.insert(
+ id: id,
+ type: type,
+ title: title,
+ body: body,
+ iconAsset: iconAsset,
+ createdAt: createdAt,
+ read: read,
+ scopeId: scopeId,
+ targetId: targetId,
+ ),
+ withReferenceMapper: (p0) => p0
+ .map((e) => (e.readTable(table), BaseReferences(db, table, e)))
+ .toList(),
+ prefetchHooksCallback: null,
+ ),
+ );
+}
+
+typedef $$AppNotificationsTableProcessedTableManager =
+ ProcessedTableManager<
+ _$SharedDatabase,
+ $AppNotificationsTable,
+ AppNotification,
+ $$AppNotificationsTableFilterComposer,
+ $$AppNotificationsTableOrderingComposer,
+ $$AppNotificationsTableAnnotationComposer,
+ $$AppNotificationsTableCreateCompanionBuilder,
+ $$AppNotificationsTableUpdateCompanionBuilder,
+ (
+ AppNotification,
+ BaseReferences<
+ _$SharedDatabase,
+ $AppNotificationsTable,
+ AppNotification
+ >,
+ ),
+ AppNotification,
+ PrefetchHooks Function()
+ >;
class $SharedDatabaseManager {
final _$SharedDatabase _db;
@@ -2685,6 +3618,8 @@ class $SharedDatabaseManager {
$$ShopInBitSettingsTableTableManager(_db, _db.shopInBitSettings);
$$ShopInBitTicketsTableTableManager get shopInBitTickets =>
$$ShopInBitTicketsTableTableManager(_db, _db.shopInBitTickets);
+ $$AppNotificationsTableTableManager get appNotifications =>
+ $$AppNotificationsTableTableManager(_db, _db.appNotifications);
}
mixin _$ShopInBitSettingsDaoMixin on DatabaseAccessor<SharedDatabase> {
@@ -2718,3 +3653,19 @@ class ShopInBitTicketsDaoManager {
_db.shopInBitTickets,
);
}
+
+mixin _$AppNotificationsDaoMixin on DatabaseAccessor<SharedDatabase> {
+ $AppNotificationsTable get appNotifications =>
+ attachedDatabase.appNotifications;
+ AppNotificationsDaoManager get managers => AppNotificationsDaoManager(this);
+}
+
+class AppNotificationsDaoManager {
+ final _$AppNotificationsDaoMixin _db;
+ AppNotificationsDaoManager(this._db);
+ $$AppNotificationsTableTableManager get appNotifications =>
+ $$AppNotificationsTableTableManager(
+ _db.attachedDatabase,
+ _db.appNotifications,
+ );
+}
diff --git a/lib/db/drift/shared_db/tables/notifications.dart b/lib/db/drift/shared_db/tables/notifications.dart
new file mode 100644
index 0000000..fc522db
--- /dev/null
+++ b/lib/db/drift/shared_db/tables/notifications.dart
@@ -0,0 +1,26 @@
+import "package:drift/drift.dart";
+
+import "shopin_bit_tickets.dart";
+
+enum AppNotificationType { shopinbit }
+
+@TableIndex(name: "app_notifications_scope", columns: {#type, #scopeId, #read})
+@TableIndex(name: "app_notifications_target", columns: {#type, #targetId})
+class AppNotifications extends Table {
+ IntColumn get id => integer().autoIncrement()();
+ TextColumn get type => textEnum<AppNotificationType>()();
+
+ TextColumn get title => text()();
+ TextColumn get body => text().withDefault(const Constant(""))();
+ TextColumn get iconAsset => text().nullable()();
+
+ TextColumn get createdAt => text()
+ .map(ShopInBitTickets.dateConverter)
+ .clientDefault(
+ () => ShopInBitTickets.dateConverter.toSql(DateTime.now()),
+ )();
+ BoolColumn get read => boolean().withDefault(const Constant(false))();
+
+ TextColumn get scopeId => text().nullable()();
+ TextColumn get targetId => text().nullable()();
+}
diff --git a/lib/db/drift/shared_db/tables/shopin_bit_tickets.dart b/lib/db/drift/shared_db/tables/shopin_bit_tickets.dart
index d18278b..7292bc7 100644
--- a/lib/db/drift/shared_db/tables/shopin_bit_tickets.dart
+++ b/lib/db/drift/shared_db/tables/shopin_bit_tickets.dart
@@ -44,6 +44,9 @@ class ShopInBitTickets extends Table {
() => ShopInBitTickets.dateConverter.toSql(DateTime.now()),
)();
+ TextColumn get lastReadAt =>
+ text().nullable().map(ShopInBitTickets.dateConverter)();
+
@override
Set<Column<Object>> get primaryKey => {apiTicketId};
diff --git a/lib/notifications/notification_card.dart b/lib/notifications/notification_card.dart
index 2176082..ed2bce8 100644
--- a/lib/notifications/notification_card.dart
+++ b/lib/notifications/notification_card.dart
@@ -21,11 +21,8 @@ import '../themes/coin_icon_provider.dart';
import '../themes/stack_colors.dart';
import '../themes/theme_providers.dart';
import '../utilities/format.dart';
-import '../utilities/text_styles.dart';
import '../utilities/util.dart';
-import '../widgets/conditional_parent.dart';
-import '../widgets/rounded_container.dart';
-import '../widgets/rounded_white_container.dart';
+import 'notification_card_layout.dart';
class NotificationCard extends ConsumerWidget {
const NotificationCard({
@@ -40,9 +37,6 @@ class NotificationCard extends ConsumerWidget {
return Format.extractDateFrom(date.millisecondsSinceEpoch ~/ 1000);
}
- static const double mobileIconSize = 24;
- static const double desktopIconSize = 30;
-
String coinIconPath(IThemeAssets assets, WidgetRef ref) {
try {
final coin =
@@ -56,137 +50,36 @@ class NotificationCard extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final isDesktop = Util.isDesktop;
+ final double iconSize = isDesktop
+ ? NotificationCardLayout.desktopIconSize
+ : NotificationCardLayout.mobileIconSize;
+ final iconFile = File(coinIconPath(ref.watch(themeAssetsProvider), ref));
- return Stack(
- children: [
- RoundedWhiteContainer(
- padding: isDesktop
- ? const EdgeInsets.symmetric(
- horizontal: 20,
- vertical: 10,
- )
- : const EdgeInsets.all(12),
- child: Row(
- children: [
- notification.changeNowId == null
- ? SvgPicture.file(
- File(
- coinIconPath(
- ref.watch(
- themeAssetsProvider,
- ),
- ref,
- ),
- ),
- width: isDesktop ? desktopIconSize : mobileIconSize,
- height: isDesktop ? desktopIconSize : mobileIconSize,
- )
- : Container(
- width: isDesktop ? desktopIconSize : mobileIconSize,
- height: isDesktop ? desktopIconSize : mobileIconSize,
- decoration: BoxDecoration(
- color: Colors.transparent,
- borderRadius: BorderRadius.circular(24),
- ),
- child: SvgPicture.file(
- File(
- coinIconPath(
- ref.watch(
- themeAssetsProvider,
- ),
- ref,
- ),
- ),
- color: Theme.of(context)
- .extension<StackColors>()!
- .accentColorDark,
- width: isDesktop ? desktopIconSize : mobileIconSize,
- height: isDesktop ? desktopIconSize : mobileIconSize,
- ),
- ),
- const SizedBox(
- width: 12,
- ),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- ConditionalParent(
- condition: isDesktop && !notification.read,
- builder: (child) => Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- child,
- Text(
- "New",
- style:
- STextStyles.desktopTextExtraExtraSmall(context)
- .copyWith(
- color: Theme.of(context)
- .extension<StackColors>()!
- .accentColorGreen,
- ),
- ),
- ],
- ),
- child: Text(
- notification.title,
- style: isDesktop
- ? STextStyles.desktopTextExtraExtraSmall(context)
- .copyWith(
- color: Theme.of(context)
- .extension<StackColors>()!
- .textDark,
- )
- : STextStyles.titleBold12(context),
- ),
- ),
- const SizedBox(
- height: 2,
- ),
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Text(
- notification.description,
- style: isDesktop
- ? STextStyles.desktopTextExtraExtraSmall(context)
- .copyWith(
- color: Theme.of(context)
- .extension<StackColors>()!
- .textSubtitle1,
- )
- : STextStyles.label(context),
- ),
- Text(
- extractPrettyDateString(notification.date),
- style: isDesktop
- ? STextStyles.desktopTextExtraExtraSmall(context)
- .copyWith(
- color: Theme.of(context)
- .extension<StackColors>()!
- .textSubtitle1,
- )
- : STextStyles.label(context),
- ),
- ],
- ),
- ],
- ),
- ),
- ],
- ),
- ),
- if (notification.read)
- Positioned.fill(
- child: RoundedContainer(
+ final Widget icon = notification.changeNowId == null
+ ? SvgPicture.file(iconFile, width: iconSize, height: iconSize)
+ : Container(
+ width: iconSize,
+ height: iconSize,
+ decoration: BoxDecoration(
+ color: Colors.transparent,
+ borderRadius: BorderRadius.circular(24),
+ ),
+ child: SvgPicture.file(
+ iconFile,
color: Theme.of(context)
.extension<StackColors>()!
- .background
- .withOpacity(0.5),
+ .accentColorDark,
+ width: iconSize,
+ height: iconSize,
),
- ),
- ],
+ );
+
+ return NotificationCardLayout(
+ icon: icon,
+ title: notification.title,
+ body: notification.description,
+ dateString: extractPrettyDateString(notification.date),
+ read: notification.read,
);
}
}
diff --git a/lib/notifications/notification_card_layout.dart b/lib/notifications/notification_card_layout.dart
new file mode 100644
index 0000000..44340ad
--- /dev/null
+++ b/lib/notifications/notification_card_layout.dart
@@ -0,0 +1,93 @@
+import 'package:flutter/material.dart';
+
+import '../themes/stack_colors.dart';
+import '../utilities/text_styles.dart';
+import '../utilities/util.dart';
+import '../widgets/rounded_container.dart';
+import '../widgets/rounded_white_container.dart';
+
+class NotificationCardLayout extends StatelessWidget {
+ const NotificationCardLayout({
+ super.key,
+ required this.icon,
+ required this.title,
+ required this.body,
+ required this.dateString,
+ required this.read,
+ });
+
+ final Widget icon;
+ final String title;
+ final String body;
+ final String dateString;
+ final bool read;
+
+ static const double mobileIconSize = 24;
+ static const double desktopIconSize = 30;
+
+ @override
+ Widget build(BuildContext context) {
+ final isDesktop = Util.isDesktop;
+ final colors = Theme.of(context).extension<StackColors>()!;
+
+ final TextStyle titleStyle = isDesktop
+ ? STextStyles.desktopTextExtraExtraSmall(
+ context,
+ ).copyWith(color: colors.textDark)
+ : STextStyles.titleBold12(context);
+ final TextStyle subStyle = isDesktop
+ ? STextStyles.desktopTextExtraExtraSmall(
+ context,
+ ).copyWith(color: colors.textSubtitle1)
+ : STextStyles.label(context);
+
+ return Stack(
+ children: [
+ RoundedWhiteContainer(
+ padding: isDesktop
+ ? const EdgeInsets.symmetric(horizontal: 20, vertical: 10)
+ : const EdgeInsets.all(12),
+ child: Row(
+ children: [
+ icon,
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Expanded(child: Text(title, style: titleStyle)),
+ if (isDesktop && !read)
+ Text(
+ "New",
+ style: STextStyles.desktopTextExtraExtraSmall(
+ context,
+ ).copyWith(color: colors.accentColorGreen),
+ ),
+ ],
+ ),
+ const SizedBox(height: 2),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Expanded(child: Text(body, style: subStyle)),
+ const SizedBox(width: 8),
+ Text(dateString, style: subStyle),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ if (read)
+ Positioned.fill(
+ child: RoundedContainer(color: colors.background.withOpacity(0.5)),
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/notifications/notification_feed_entry.dart b/lib/notifications/notification_feed_entry.dart
new file mode 100644
index 0000000..2d430f0
--- /dev/null
+++ b/lib/notifications/notification_feed_entry.dart
@@ -0,0 +1,30 @@
+import '../db/drift/shared_db/shared_database.dart';
+import '../models/notification_model.dart';
+
+sealed class NotificationFeedEntry {
+ DateTime get date;
+}
+
+class HiveFeedEntry extends NotificationFeedEntry {
+ HiveFeedEntry(this.model);
+ final NotificationModel model;
+ @override
+ DateTime get date => model.date;
+}
+
+class AppFeedEntry extends NotificationFeedEntry {
+ AppFeedEntry(this.notification);
+ final AppNotification notification;
+ @override
+ DateTime get date => notification.createdAt;
+}
+
+List<NotificationFeedEntry> mergeNotificationFeed(
+ List<NotificationModel> hive,
+ List<AppNotification> app,
+) {
+ return <NotificationFeedEntry>[
+ ...hive.map(HiveFeedEntry.new),
+ ...app.map(AppFeedEntry.new),
+ ]..sort((a, b) => b.date.compareTo(a.date));
+}
diff --git a/lib/notifications/notification_feed_entry_card.dart b/lib/notifications/notification_feed_entry_card.dart
new file mode 100644
index 0000000..03973d7
--- /dev/null
+++ b/lib/notifications/notification_feed_entry_card.dart
@@ -0,0 +1,29 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../providers/ui/unread_notifications_provider.dart';
+import 'notification_card.dart';
+import 'notification_feed_entry.dart';
+import 'shopinbit_notification_card.dart';
+
+class NotificationFeedEntryCard extends ConsumerWidget {
+ const NotificationFeedEntryCard({super.key, required this.entry});
+
+ final NotificationFeedEntry entry;
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final entry = this.entry;
+ if (entry is HiveFeedEntry && entry.model.read == false) {
+ ref
+ .read(unreadNotificationsStateProvider.state)
+ .state
+ .add(entry.model.id);
+ }
+
+ return switch (entry) {
+ HiveFeedEntry e => NotificationCard(notification: e.model),
+ AppFeedEntry e => ShopInBitNotificationCard(notification: e.notification),
+ };
+ }
+}
diff --git a/lib/notifications/shopinbit_notification_card.dart b/lib/notifications/shopinbit_notification_card.dart
new file mode 100644
index 0000000..1ae8eaf
--- /dev/null
+++ b/lib/notifications/shopinbit_notification_card.dart
@@ -0,0 +1,38 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_svg/svg.dart';
+
+import '../db/drift/shared_db/shared_database.dart';
+import '../themes/stack_colors.dart';
+import '../utilities/assets.dart';
+import '../utilities/format.dart';
+import '../utilities/util.dart';
+import 'notification_card_layout.dart';
+
+class ShopInBitNotificationCard extends StatelessWidget {
+ const ShopInBitNotificationCard({super.key, required this.notification});
+
+ final AppNotification notification;
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = Theme.of(context).extension<StackColors>()!;
+ final double iconSize = Util.isDesktop
+ ? NotificationCardLayout.desktopIconSize
+ : NotificationCardLayout.mobileIconSize;
+
+ return NotificationCardLayout(
+ icon: SvgPicture.asset(
+ notification.iconAsset ?? Assets.svg.sib,
+ width: iconSize,
+ height: iconSize,
+ color: colors.accentColorDark,
+ ),
+ title: notification.title,
+ body: notification.body,
+ dateString: Format.extractDateFrom(
+ notification.createdAt.millisecondsSinceEpoch ~/ 1000,
+ ),
+ read: notification.read,
+ );
+ }
+}
diff --git a/lib/pages/home_view/home_view.dart b/lib/pages/home_view/home_view.dart
index fcd4259..12867fc 100644
--- a/lib/pages/home_view/home_view.dart
+++ b/lib/pages/home_view/home_view.dart
@@ -18,6 +18,7 @@ import 'package:flutter_svg/svg.dart';
import '../../app_config.dart';
import '../../providers/global/notifications_provider.dart';
import '../../providers/global/prefs_provider.dart';
+import '../../providers/global/shopin_bit_service_provider.dart';
import '../../providers/ui/home_view_index_provider.dart';
import '../../providers/ui/unread_notifications_provider.dart';
import '../../route_generator.dart';
@@ -346,11 +347,7 @@ class _HomeViewState extends ConsumerState<HomeView> {
context,
).extension<StackColors>()!.backgroundAppBar,
icon:
- ref.watch(
- notificationsProvider.select(
- (value) => value.hasUnreadNotifications,
- ),
- )
+ ref.watch(pAnyGlobalUnreadNotifications)
? SvgPicture.file(
File(
ref.watch(
@@ -362,11 +359,7 @@ class _HomeViewState extends ConsumerState<HomeView> {
width: 20,
height: 20,
color:
- ref.watch(
- notificationsProvider.select(
- (value) => value.hasUnreadNotifications,
- ),
- )
+ ref.watch(pAnyGlobalUnreadNotifications)
? null
: Theme.of(
context,
@@ -377,11 +370,7 @@ class _HomeViewState extends ConsumerState<HomeView> {
width: 20,
height: 20,
color:
- ref.watch(
- notificationsProvider.select(
- (value) => value.hasUnreadNotifications,
- ),
- )
+ ref.watch(pAnyGlobalUnreadNotifications)
? null
: Theme.of(
context,
diff --git a/lib/pages/notification_views/notifications_view.dart b/lib/pages/notification_views/notifications_view.dart
index 417d10b..053980a 100644
--- a/lib/pages/notification_views/notifications_view.dart
+++ b/lib/pages/notification_views/notifications_view.dart
@@ -8,12 +8,15 @@
*
*/
+import 'dart:async';
+
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
-import '../../notifications/notification_card.dart';
-import '../../providers/providers.dart';
-import '../../providers/ui/unread_notifications_provider.dart';
+import '../../notifications/notification_feed_entry_card.dart';
+import '../../providers/global/shopin_bit_service_provider.dart';
+import '../../providers/ui/notification_feed_provider.dart';
+import '../../services/shopinbit/shopinbit_service.dart';
import '../../themes/stack_colors.dart';
import '../../utilities/text_styles.dart';
import '../../widgets/background.dart';
@@ -32,29 +35,25 @@ class NotificationsView extends ConsumerStatefulWidget {
}
class _NotificationsViewState extends ConsumerState<NotificationsView> {
+ late final ShopInBitService _shopInBitService;
+
@override
void initState() {
super.initState();
+ _shopInBitService = ref.read(pShopinBitService);
}
@override
void dispose() {
+ if (widget.walletId == null) {
+ unawaited(_shopInBitService.markAllNotificationsRead());
+ }
super.dispose();
}
@override
Widget build(BuildContext context) {
- final notifications =
- widget.walletId == null
- ? ref.watch(
- notificationsProvider.select((value) => value.notifications),
- )
- : ref
- .watch(
- notificationsProvider.select((value) => value.notifications),
- )
- .where((element) => element.walletId == widget.walletId)
- .toList(growable: false);
+ final entries = ref.watch(pNotificationFeed(widget.walletId));
return Background(
child: Scaffold(
@@ -70,54 +69,44 @@ class _NotificationsViewState extends ConsumerState<NotificationsView> {
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(12),
- child:
- notifications.isNotEmpty
- ? Column(
- children: [
- Expanded(
- child: ListView.builder(
- shrinkWrap: true,
- itemCount: notifications.length,
- itemBuilder: (builderContext, index) {
- final notification = notifications[index];
- if (notification.read == false) {
- ref
- .read(
- unreadNotificationsStateProvider.state,
- )
- .state
- .add(notification.id);
- }
- return Padding(
- padding: const EdgeInsets.all(4),
- child: NotificationCard(
- notification: notifications[index],
- ),
- );
- },
- ),
+ child: entries.isNotEmpty
+ ? Column(
+ children: [
+ Expanded(
+ child: ListView.builder(
+ shrinkWrap: true,
+ itemCount: entries.length,
+ itemBuilder: (builderContext, index) {
+ return Padding(
+ padding: const EdgeInsets.all(4),
+ child: NotificationFeedEntryCard(
+ entry: entries[index],
+ ),
+ );
+ },
),
- ],
- )
- : Column(
- crossAxisAlignment: CrossAxisAlignment.center,
- children: [
- Padding(
- padding: const EdgeInsets.all(4),
- child: RoundedWhiteContainer(
- child: Center(
- child: FittedBox(
- fit: BoxFit.scaleDown,
- child: Text(
- "Notifications will appear here",
- style: STextStyles.itemSubtitle(context),
- ),
+ ),
+ ],
+ )
+ : Column(
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ Padding(
+ padding: const EdgeInsets.all(4),
+ child: RoundedWhiteContainer(
+ child: Center(
+ child: FittedBox(
+ fit: BoxFit.scaleDown,
+ child: Text(
+ "Notifications will appear here",
+ style: STextStyles.itemSubtitle(context),
),
),
),
),
- ],
- ),
+ ),
+ ],
+ ),
),
),
),
diff --git a/lib/pages/shopinbit/shopinbit_ticket_detail.dart b/lib/pages/shopinbit/shopinbit_ticket_detail.dart
index 1c03051..dfeb2d3 100644
--- a/lib/pages/shopinbit/shopinbit_ticket_detail.dart
+++ b/lib/pages/shopinbit/shopinbit_ticket_detail.dart
@@ -92,6 +92,10 @@ class _ShopInBitTicketDetailState extends ConsumerState<ShopInBitTicketDetail>
@override
void dispose() {
+ if (_shopinBitService.viewingTicketId == _id) {
+ unawaited(_shopinBitService.markTicketRead(_id));
+ _shopinBitService.viewingTicketId = null;
+ }
WidgetsBinding.instance.removeObserver(this);
_pollingTimer?.cancel();
_pollingTimer = null;
@@ -99,8 +103,34 @@ class _ShopInBitTicketDetailState extends ConsumerState<ShopInBitTicketDetail>
super.dispose();
}
+ bool get _isChatVisible {
+ final lifecycle = WidgetsBinding.instance.lifecycleState;
+ final foregrounded =
+ lifecycle == null || lifecycle == AppLifecycleState.resumed;
+ return mounted &&
+ foregrounded &&
+ (ModalRoute.of(context)?.isCurrent ?? true);
+ }
+
+ void _syncViewingFlag() {
+ if (_isChatVisible) {
+ _shopinBitService.viewingTicketId = _id;
+ } else if (_shopinBitService.viewingTicketId == _id) {
+ _shopinBitService.viewingTicketId = null;
+ }
+ }
+
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+
+ _syncViewingFlag();
+ }
+
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
+ _syncViewingFlag();
+
// Always continue polling on desktop
if (Util.isDesktop) return;
@@ -117,6 +147,7 @@ class _ShopInBitTicketDetailState extends ConsumerState<ShopInBitTicketDetail>
Timer? _pollingTimer;
Future<void> _poll() async {
_pollInFlight = true;
+ _syncViewingFlag();
bool ok = false;
try {
await _refresh();
@@ -136,6 +167,12 @@ class _ShopInBitTicketDetailState extends ConsumerState<ShopInBitTicketDetail>
if (_paused) return;
final ticket = ref.read(pShopInBitTicket(_id)).asData?.value;
+
+ // The user is viewing this ticket, so treat the conversation as read.
+ if (_isChatVisible && ticket != null && ticket.hasUnreadAgentMessage) {
+ unawaited(_shopinBitService.markTicketRead(_id));
+ }
+
final isTerminal =
ticket != null && TicketState.fromString(ticket.statusRaw).isTerminal;
// Just check terminal tickets less often. Was hitting limits in testing.
@@ -292,8 +329,11 @@ class _ShopInBitTicketDetailState extends ConsumerState<ShopInBitTicketDetail>
// loop reconciles regardless, so a failure pulling the server's copy in
// here must not roll the (already sent) message back. Fold it in if we
// can; otherwise leave the optimistic bubble for the next refresh.
+ // forceMessages: the user's own message doesn't move lastAgentMessageAt,
+ // so an ungated refresh would skip the fetch and the bubble's removal
+ // below would make the just-sent message vanish from the conversation.
try {
- await _refresh();
+ await _shopinBitService.refreshOne(_id, forceMessages: true);
} catch (_) {}
if (mounted) setState(() => _pending.remove(optimistic));
} else {
diff --git a/lib/pages_desktop_specific/desktop_menu_item.dart b/lib/pages_desktop_specific/desktop_menu_item.dart
index 3decfae..60daf23 100644
--- a/lib/pages_desktop_specific/desktop_menu_item.dart
+++ b/lib/pages_desktop_specific/desktop_menu_item.dart
@@ -15,7 +15,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/svg.dart';
import '../providers/desktop/current_desktop_menu_item.dart';
-import '../providers/global/notifications_provider.dart';
+import '../providers/global/shopin_bit_service_provider.dart';
import '../themes/stack_colors.dart';
import '../themes/theme_providers.dart';
import '../utilities/assets.dart';
@@ -117,9 +117,8 @@ class DesktopNotificationsIcon extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
- return ref.watch(
- notificationsProvider.select((value) => value.hasUnreadNotifications),
- )
+ final hasUnread = ref.watch(pAnyGlobalUnreadNotifications);
+ return hasUnread
? SvgPicture.file(
File(
ref.watch(themeProvider.select((value) => value.assets.bellNew)),
@@ -132,14 +131,8 @@ class DesktopNotificationsIcon extends ConsumerWidget {
width: 20,
height: 20,
color:
- ref.watch(
- notificationsProvider.select(
- (value) => value.hasUnreadNotifications,
- ),
- )
- ? null
- : DesktopMenuItemId.notifications ==
- ref.watch(currentDesktopMenuItemProvider.state).state
+ DesktopMenuItemId.notifications ==
+ ref.watch(currentDesktopMenuItemProvider.state).state
? Theme.of(context).extension<StackColors>()!.accentColorDark
: Theme.of(
context,
diff --git a/lib/pages_desktop_specific/notifications/desktop_notifications_view.dart b/lib/pages_desktop_specific/notifications/desktop_notifications_view.dart
index e4c04fa..7b7f948 100644
--- a/lib/pages_desktop_specific/notifications/desktop_notifications_view.dart
+++ b/lib/pages_desktop_specific/notifications/desktop_notifications_view.dart
@@ -8,11 +8,15 @@
*
*/
+import 'dart:async';
+
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
-import '../../notifications/notification_card.dart';
-import '../../providers/providers.dart';
-import '../../providers/ui/unread_notifications_provider.dart';
+
+import '../../notifications/notification_feed_entry_card.dart';
+import '../../providers/global/shopin_bit_service_provider.dart';
+import '../../providers/ui/notification_feed_provider.dart';
+import '../../services/shopinbit/shopinbit_service.dart';
import '../../themes/stack_colors.dart';
import '../../utilities/text_styles.dart';
import '../../widgets/desktop/desktop_app_bar.dart';
@@ -31,10 +35,25 @@ class DesktopNotificationsView extends ConsumerStatefulWidget {
class _DesktopNotificationsViewState
extends ConsumerState<DesktopNotificationsView> {
+ late final ShopInBitService _shopInBitService;
+
+ @override
+ void initState() {
+ super.initState();
+ _shopInBitService = ref.read(pShopinBitService);
+ }
+
+ @override
+ void dispose() {
+ // Viewing the (always-global) desktop list acknowledges ShopinBit
+ // notifications, clearing the bell/feed like the wallet ones.
+ unawaited(_shopInBitService.markAllNotificationsRead());
+ super.dispose();
+ }
+
@override
Widget build(BuildContext context) {
- final notifications =
- ref.watch(notificationsProvider.select((value) => value.notifications));
+ final entries = ref.watch(pNotificationFeed(null));
return DesktopScaffold(
background: Theme.of(context).extension<StackColors>()!.background,
@@ -42,13 +61,10 @@ class _DesktopNotificationsViewState
isCompactHeight: true,
leading: Padding(
padding: const EdgeInsets.only(left: 24),
- child: Text(
- "Notifications",
- style: STextStyles.desktopH3(context),
- ),
+ child: Text("Notifications", style: STextStyles.desktopH3(context)),
),
),
- body: notifications.isEmpty
+ body: entries.isEmpty
? Column(
children: [
Padding(
@@ -66,24 +82,14 @@ class _DesktopNotificationsViewState
)
: ListView.builder(
primary: false,
- itemCount: notifications.length,
+ itemCount: entries.length,
itemBuilder: (context, index) {
- final notification = notifications[index];
- if (notification.read == false) {
- ref
- .read(unreadNotificationsStateProvider.state)
- .state
- .add(notification.id);
- }
-
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 5,
),
- child: NotificationCard(
- notification: notification,
- ),
+ child: NotificationFeedEntryCard(entry: entries[index]),
);
},
),
diff --git a/lib/providers/global/shopin_bit_service_provider.dart b/lib/providers/global/shopin_bit_service_provider.dart
index 102a59f..bce681b 100644
--- a/lib/providers/global/shopin_bit_service_provider.dart
+++ b/lib/providers/global/shopin_bit_service_provider.dart
@@ -1,10 +1,12 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../db/drift/shared_db/shared_database.dart';
+import '../../db/drift/shared_db/tables/notifications.dart';
import '../../external_api_keys.dart';
import '../../services/shopinbit/shopinbit_api.dart';
import '../../services/shopinbit/shopinbit_service.dart';
import '../db/drift_provider.dart';
+import 'notifications_provider.dart';
final pShopinBitService = Provider(
(ref) => ShopInBitService(
@@ -22,17 +24,19 @@ final pShopInBitSettings = StreamProvider.autoDispose<ShopInBitSetting?>(
(ref) => ref.watch(pSharedDrift).shopInBitSettingsDao.watchCurrentSettings(),
);
-/// All tickets for the active customer key, newest first.
+/// All tickets for the active customer key, newest first. Watches the key so
+/// a key created after startup or switched at runtime re-scopes the list.
final pShopInBitTickets = StreamProvider.autoDispose<List<ShopInBitTicket>>((
ref,
-) async* {
- final db = ref.watch(pSharedDrift);
- final settings = await db.shopInBitSettingsDao.getCurrentSettings();
- if (settings == null) {
- yield const [];
- return;
+) {
+ final customerKey = ref.watch(_pShopInBitCustomerKey);
+ if (customerKey == null) {
+ return Stream.value(const <ShopInBitTicket>[]);
}
- yield* db.shopInBitTicketsDao.watchByCustomerKey(settings.customerKey);
+ return ref
+ .watch(pSharedDrift)
+ .shopInBitTicketsDao
+ .watchByCustomerKey(customerKey);
});
final pShopInBitTicket = StreamProvider.autoDispose
@@ -40,3 +44,49 @@ final pShopInBitTicket = StreamProvider.autoDispose
(ref, apiTicketId) =>
ref.watch(pSharedDrift).shopInBitTicketsDao.watchByApiId(apiTicketId),
);
+
+final _pShopInBitCustomerKey = Provider.autoDispose<String?>(
+ (ref) =>
+ ref.watch(pShopInBitSettings.select((s) => s.asData?.value?.customerKey)),
+);
+
+/// ShopinBit notifications for the active customer key, newest first (feed).
+final pShopInBitNotifications =
+ StreamProvider.autoDispose<List<AppNotification>>((ref) {
+ final customerKey = ref.watch(_pShopInBitCustomerKey);
+ if (customerKey == null) {
+ return Stream.value(const <AppNotification>[]);
+ }
+ return ref
+ .watch(pSharedDrift)
+ .appNotificationsDao
+ .watchByScope(AppNotificationType.shopinbit, customerKey);
+ });
+
+/// Unread ShopinBit notification count for the active customer key (bell).
+final pShopInBitNotificationUnreadCount = StreamProvider.autoDispose<int>((
+ ref,
+) {
+ final customerKey = ref.watch(_pShopInBitCustomerKey);
+ if (customerKey == null) {
+ return Stream.value(0);
+ }
+ return ref
+ .watch(pSharedDrift)
+ .appNotificationsDao
+ .watchUnreadCount(
+ type: AppNotificationType.shopinbit,
+ scopeId: customerKey,
+ );
+});
+
+/// True when the global notifications bell should light: any unread Hive
+/// notification, or any unread ShopinBit notification for the active key.
+final pAnyGlobalUnreadNotifications = Provider.autoDispose<bool>((ref) {
+ final hive = ref.watch(
+ notificationsProvider.select((value) => value.hasUnreadNotifications),
+ );
+ final sib =
+ (ref.watch(pShopInBitNotificationUnreadCount).asData?.value ?? 0) > 0;
+ return hive || sib;
+});
diff --git a/lib/providers/ui/notification_feed_provider.dart b/lib/providers/ui/notification_feed_provider.dart
new file mode 100644
index 0000000..ab49552
--- /dev/null
+++ b/lib/providers/ui/notification_feed_provider.dart
@@ -0,0 +1,28 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../../db/drift/shared_db/shared_database.dart';
+import '../../notifications/notification_feed_entry.dart';
+import '../global/notifications_provider.dart';
+import '../global/shopin_bit_service_provider.dart';
+
+/// The merged notification feed, newest first, shared by the mobile and
+/// desktop notifications views. Pass a walletId to scope the list to that
+/// wallet's Hive notifications (ShopinBit rows are account-level, not
+/// per-wallet, so they only appear in the global feed); null is the global
+/// feed.
+final pNotificationFeed = Provider.autoDispose
+ .family<List<NotificationFeedEntry>, String?>((ref, walletId) {
+ final all = ref.watch(
+ notificationsProvider.select((value) => value.notifications),
+ );
+ final hive = walletId == null
+ ? all
+ : all
+ .where((element) => element.walletId == walletId)
+ .toList(growable: false);
+ final sib = walletId == null
+ ? (ref.watch(pShopInBitNotifications).asData?.value ??
+ const <AppNotification>[])
+ : const <AppNotification>[];
+ return mergeNotificationFeed(hive, sib);
+ });
diff --git a/lib/services/notifications_api.dart b/lib/services/notifications_api.dart
index 13dec90..4263c95 100644
--- a/lib/services/notifications_api.dart
+++ b/lib/services/notifications_api.dart
@@ -13,6 +13,7 @@ import 'dart:async';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import '../models/notification_model.dart';
+import '../utilities/logger.dart';
import '../utilities/prefs.dart';
import 'notifications_service.dart';
@@ -24,7 +25,8 @@ abstract final class NotificationApi {
static Future<NotificationDetails> _notificationDetails() async {
return const NotificationDetails(
android: AndroidNotificationDetails(
- 'channel id', 'channel name',
+ 'channel id',
+ 'channel name',
channelDescription: 'channel description',
// importance: Importance.max,
priority: Priority.high,
@@ -84,6 +86,23 @@ abstract final class NotificationApi {
static late Prefs prefs;
static late NotificationsService notificationsService;
+ static Future<int> _showOsNotification({
+ required String title,
+ required String body,
+ String? payload,
+ }) async {
+ await init();
+ final id = await prefs.incrementCurrentNotificationIndex();
+ await _notifications.show(
+ id,
+ title,
+ body,
+ await _notificationDetails(),
+ payload: payload,
+ );
+ return id;
+ }
+
static Future<void> showNotification({
required String title,
required String body,
@@ -98,9 +117,11 @@ abstract final class NotificationApi {
String? changeNowId,
String? payload,
}) async {
- await init();
- await prefs.incrementCurrentNotificationIndex();
- final id = prefs.currentNotificationId;
+ final id = await _showOsNotification(
+ title: title,
+ body: body,
+ payload: payload,
+ );
String confirms = "";
if (txid != null &&
@@ -123,15 +144,21 @@ abstract final class NotificationApi {
changeNowId: changeNowId,
);
- await Future.wait([
- _notifications.show(
- id,
- title,
- body,
- await _notificationDetails(),
- payload: payload,
- ),
- notificationsService.add(model, true),
- ]);
+ await notificationsService.add(model, true);
+ }
+
+ static Future<void> showLocalOnly({
+ required String title,
+ required String body,
+ }) async {
+ try {
+ await _showOsNotification(title: title, body: body);
+ } catch (e, s) {
+ Logging.instance.w(
+ "NotificationApi.showLocalOnly failed",
+ error: e,
+ stackTrace: s,
+ );
+ }
}
}
diff --git a/lib/services/shopinbit/shopinbit_service.dart b/lib/services/shopinbit/shopinbit_service.dart
index ac0a4da..b11ae39 100644
--- a/lib/services/shopinbit/shopinbit_service.dart
+++ b/lib/services/shopinbit/shopinbit_service.dart
@@ -5,8 +5,10 @@ import "package:drift/drift.dart";
import "package:flutter/foundation.dart";
import "../../db/drift/shared_db/shared_database.dart";
+import "../../db/drift/shared_db/tables/notifications.dart";
import "../../models/shopinbit/shopinbit_enums.dart";
import "../../utilities/logger.dart";
+import "../notifications_api.dart";
import "src/api_response.dart";
import "src/client.dart";
import "src/models/message.dart";
@@ -15,13 +17,28 @@ import "src/models/ticket.dart";
/// Display name sent to ShopinBit as `customer_pseudonym`.
const String kShopInBitCustomerPseudonym = "Satoshi";
+/// A refresh currently in flight for one ticket. [forced] records whether it
+/// will (re)fetch the message list, so a later forced caller knows whether it
+/// can safely piggy-back on this one or must run its own forced refresh.
+class _InFlightRefresh {
+ const _InFlightRefresh(this.completer, this.forced);
+ final Completer<void> completer;
+ final bool forced;
+}
+
class ShopInBitService {
ShopInBitService({required this.client, required this.db});
final ShopInBitClient client;
final SharedDatabase db;
- final Map<int, Completer<void>> _inFlight = {};
+ final Map<int, _InFlightRefresh> _inFlight = {};
+
+ /// The ticket whose conversation is currently on screen, if any. Kept in
+ /// sync by the ticket-detail view with its actual visibility (topmost route,
+ /// app foregrounded). A new reply for it skips the system notification and
+ /// is recorded already-read — the user is looking right at it.
+ int? viewingTicketId;
// -- Customer key --
@@ -160,6 +177,63 @@ class ShopInBitService {
return true;
}
+ /// Mark a ticket read, so it stops surfacing as unread. Read state is
+ /// local-only and never round-trips to the API. Also clears the ticket's
+ /// notification rows so the bell/feed drop it in step with the dot.
+ ///
+ /// The recorded read time is clamped up to the ticket's own
+ /// `lastAgentMessageAt`: unread is derived by comparing that (server-set)
+ /// timestamp against lastReadAt, so a client clock lagging real time would
+ /// otherwise write a read time earlier than the reply and leave the dot lit
+ /// after the user has plainly read it. All times are UTC.
+ ///
+ /// Best-effort: callers fire-and-forget (poll loop, view dispose), so a DB
+ /// failure is logged, not thrown.
+ Future<void> markTicketRead(int apiTicketId) async {
+ try {
+ final ticket = await db.shopInBitTicketsDao.getByApiId(apiTicketId);
+ final DateTime now = DateTime.now().toUtc();
+ final DateTime? lastAgent = ticket?.lastAgentMessageAt?.toUtc();
+ final DateTime readAt = (lastAgent != null && lastAgent.isAfter(now))
+ ? lastAgent
+ : now;
+ await db.shopInBitTicketsDao.markRead(apiTicketId, readAt);
+ await db.appNotificationsDao.markReadByTarget(
+ AppNotificationType.shopinbit,
+ "$apiTicketId",
+ );
+ } catch (e, s) {
+ Logging.instance.w(
+ "ShopInBitService.markTicketRead failed",
+ error: e,
+ stackTrace: s,
+ );
+ }
+ }
+
+ /// Mark the active customer key's ShopinBit notifications read — called when
+ /// the user views the notifications list, so the bell/feed clear like the
+ /// wallet notifications do. Per-ticket dots (lastReadAt) are untouched.
+ ///
+ /// Best-effort: callers fire-and-forget from view dispose, so a DB failure
+ /// is logged, not thrown as an unhandled async error.
+ Future<void> markAllNotificationsRead() async {
+ try {
+ final settings = await db.shopInBitSettingsDao.getCurrentSettings();
+ if (settings == null) return;
+ await db.appNotificationsDao.markAllRead(
+ type: AppNotificationType.shopinbit,
+ scopeId: settings.customerKey,
+ );
+ } catch (e, s) {
+ Logging.instance.w(
+ "ShopInBitService.markAllNotificationsRead failed",
+ error: e,
+ stackTrace: s,
+ );
+ }
+ }
+
// -- Internals --
/// Hydrate-or-update one ticket. Branches on whether the row already
@@ -177,11 +251,26 @@ class ShopInBitService {
) {
final int id = ref.id;
- final Completer<void>? pending = _inFlight[id];
- if (pending != null) return pending.future;
+ final _InFlightRefresh? pending = _inFlight[id];
+ if (pending != null) {
+ // Join the in-flight refresh only if it will do what we need. An unforced
+ // caller is always satisfied; a forced caller is satisfied only if the
+ // in-flight refresh is itself forced (it will fetch messages too).
+ // Otherwise joining would silently drop our force, so wait for the
+ // in-flight one to settle and then run our own forced refresh — a
+ // just-sent message MUST actually be fetched, or the view removes its
+ // optimistic bubble and the message vanishes until the next refresh.
+ if (pending.forced || !forceUpdateMessages) {
+ return pending.completer.future;
+ }
+ return pending.completer.future.then(
+ (_) => _refreshRef(ref, customerKey, true),
+ onError: (_, _) => _refreshRef(ref, customerKey, true),
+ );
+ }
final Completer<void> completer = Completer<void>();
- _inFlight[id] = completer;
+ _inFlight[id] = _InFlightRefresh(completer, forceUpdateMessages);
// Fire-and-forget: _runRefresh should never throw (it routes errors through
// the completer), so the unawaited future is safe. Every caller —
@@ -265,12 +354,12 @@ class ShopInBitService {
} else {
final List<TicketMessage>? messages;
+ // Use the same predicate as the notify path (its documented single
+ // source of truth): a null stored timestamp with an incoming reply
+ // counts as new, so a ticket's FIRST agent reply is fetched — not
+ // just bannered — instead of being skipped and never pulled in.
if (forceUpdateMessages ||
- (existing.lastAgentMessageAt != null &&
- status.lastAgentMessageAt != null &&
- status.lastAgentMessageAt!.toUtc().isAfter(
- existing.lastAgentMessageAt!.toUtc(),
- )) ||
+ _hasNewerAgentMessage(existing, status) ||
existing.messages.isEmpty) {
messages = await fetchMessages();
if (kDebugMode) {
@@ -369,9 +458,9 @@ class ShopInBitService {
trackingLink: status == null
? const Value.absent()
: Value(status.trackingLink),
- lastAgentMessageAt: status == null
+ lastAgentMessageAt: status?.lastAgentMessageAt == null
? const Value.absent()
- : Value(status.lastAgentMessageAt),
+ : Value(status!.lastAgentMessageAt),
deliveryCountry: full == null
? const Value.absent()
: Value(full.deliveryCountry),
@@ -395,6 +484,64 @@ class ShopInBitService {
updatedAt: Value(DateTime.now()),
),
);
+
+ await _maybeNotifyNewReply(existing, status);
+ }
+
+ static bool _hasNewerAgentMessage(
+ ShopInBitTicket existing,
+ TicketStatus status,
+ ) {
+ final DateTime? incoming = status.lastAgentMessageAt;
+ if (incoming == null) return false;
+ final DateTime? stored = existing.lastAgentMessageAt;
+ return stored == null || incoming.isAfter(stored);
+ }
+
+ Future<void> _maybeNotifyNewReply(
+ ShopInBitTicket existing,
+ TicketStatus? status,
+ ) async {
+ if (status == null || !_hasNewerAgentMessage(existing, status)) return;
+ final DateTime newAgentAt = status.lastAgentMessageAt!;
+ // A message the user has already read is not news, even when the stored
+ // agent timestamp is missing (bare-insert rows, or an API response that
+ // omitted the field on an earlier poll).
+ final DateTime? lastReadAt = existing.lastReadAt;
+ if (lastReadAt != null && !newAgentAt.isAfter(lastReadAt)) return;
+
+ const String title = "ShopinBit";
+ final String body = "New reply to request ${existing.ticketNumber}";
+
+ final bool viewing = existing.apiTicketId == viewingTicketId;
+
+ // iconAsset stays null: the card falls back to the ShopinBit brand icon,
+ // and not persisting the path means an asset move can't strand old rows.
+ await db.appNotificationsDao.add(
+ AppNotificationsCompanion.insert(
+ type: AppNotificationType.shopinbit,
+ title: title,
+ body: Value(body),
+ scopeId: Value(existing.customerKey),
+ targetId: Value("${existing.apiTicketId}"),
+ read: Value(viewing),
+ ),
+ );
+
+ if (viewing) {
+ // Mark read here rather than waiting for the detail view's next poll:
+ // that poll reads a not-yet-requeried snapshot and would leave the
+ // bell/dot lit for a full interval while the user reads the reply.
+ await markTicketRead(existing.apiTicketId);
+ } else {
+ unawaited(NotificationApi.showLocalOnly(title: title, body: body));
+ }
+
+ // Keep this scope's notification history bounded now that a row was added.
+ await db.appNotificationsDao.pruneScope(
+ AppNotificationType.shopinbit,
+ existing.customerKey,
+ );
}
}
diff --git a/lib/utilities/prefs.dart b/lib/utilities/prefs.dart
index 2030853..56013d6 100644
--- a/lib/utilities/prefs.dart
+++ b/lib/utilities/prefs.dart
@@ -148,18 +148,25 @@ class Prefs extends ChangeNotifier {
int get currentNotificationId => _currentNotificationId;
- Future<void> incrementCurrentNotificationIndex() async {
+ /// Bumps the shared OS-notification id counter and returns the id it
+ /// allocated. The bump happens synchronously before the persist is awaited,
+ /// so concurrent callers each get a distinct id — use the returned value,
+ /// not a later read of [currentNotificationId], which by the time this
+ /// completes may already belong to another caller.
+ Future<int> incrementCurrentNotificationIndex() async {
if (_currentNotificationId <= Constants.notificationsMax) {
_currentNotificationId++;
} else {
_currentNotificationId = 0;
}
+ final int id = _currentNotificationId;
await DB.instance.put<dynamic>(
boxName: DB.boxNamePrefs,
key: "currentNotificationId",
value: _currentNotificationId,
);
notifyListeners();
+ return id;
}
Future<int> _getCurrentNotificationIndex() async {
Why this scored 19/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.