feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
+4
View File
@@ -0,0 +1,4 @@
export 'auth_provider.dart';
export '../community/community.dart';
export '../community/community_provider.dart';
export '../community/community_storage.dart';
+111
View File
@@ -0,0 +1,111 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import '../community/community.dart';
import '../community/community_provider.dart';
enum AuthStatus { unknown, unauthenticated, authenticated }
class AuthState {
final AuthStatus status;
final Community? community;
const AuthState({required this.status, this.community});
}
/// Restores the active community without making connectivity load-bearing.
/// The relay session owns connection recovery after startup.
class AuthNotifier extends AsyncNotifier<AuthState> {
@override
Future<AuthState> build() async {
// Read from storage directly — NOT from community providers.
// Watching community providers here would create a circular dependency
// because authenticateWithCommunity() writes to those providers.
final storage = ref.read(communityStorageProvider);
final communities = await storage.loadAll();
if (communities.isEmpty) {
return const AuthState(status: AuthStatus.unauthenticated);
}
var activeId = await storage.loadActiveId();
while (communities.isNotEmpty) {
final active =
activeId != null && communities.any((w) => w.id == activeId)
? communities.firstWhere((w) => w.id == activeId)
: communities.first;
await storage.saveActiveId(active.id);
if (_hasValidNsec(active.nsec)) {
return AuthState(status: AuthStatus.authenticated, community: active);
}
await storage.remove(active.id);
communities.removeWhere((community) => community.id == active.id);
activeId = null;
ref.invalidate(communityListProvider);
ref.invalidate(activeCommunityProvider);
}
await storage.clearActiveId();
return const AuthState(status: AuthStatus.unauthenticated);
}
/// Authenticate with a community. Saves it and switches to it.
/// Writes to storage directly to avoid circular dependency with community
/// providers.
Future<void> authenticateWithCommunity(Community community) async {
final storage = ref.read(communityStorageProvider);
await storage.save(community);
await storage.saveActiveId(community.id);
// Invalidate community providers so other consumers pick up the new data.
ref.invalidate(communityListProvider);
ref.invalidate(activeCommunityProvider);
state = AsyncData(
AuthState(status: AuthStatus.authenticated, community: community),
);
}
Future<void> signOut() async {
final storage = ref.read(communityStorageProvider);
final activeId = await storage.loadActiveId();
if (activeId != null) {
await storage.remove(activeId);
await storage.clearActiveId();
}
// Check if other communities remain — switch to the next one instead of
// forcing the user back to the pairing screen.
final remaining = await storage.loadAll();
// Invalidate community providers so other consumers pick up the change.
ref.invalidate(communityListProvider);
ref.invalidate(activeCommunityProvider);
if (remaining.isNotEmpty) {
final next = remaining.first;
await storage.saveActiveId(next.id);
// Re-run build() to validate the next community's credentials.
ref.invalidateSelf();
await future;
} else {
state = const AsyncData(AuthState(status: AuthStatus.unauthenticated));
}
}
}
bool _hasValidNsec(String? nsec) {
if (nsec == null || nsec.isEmpty) return false;
try {
final decoded = nostr.Nip19.decode(payload: nsec);
return decoded.prefix == nostr.Nip19Prefix.nsec &&
decoded.data.length == 64;
} catch (_) {
return false;
}
}
final authProvider = AsyncNotifierProvider<AuthNotifier, AuthState>(
AuthNotifier.new,
);
+19
View File
@@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'l10n/l10n.dart';
Future<void> copyToClipboard(
BuildContext context,
String text, {
String? message,
}) async {
await Clipboard.setData(ClipboardData(text: text));
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message ?? context.l10n.copied),
duration: const Duration(seconds: 2),
),
);
}
@@ -0,0 +1,85 @@
import 'package:uuid/uuid.dart';
const _uuid = Uuid();
const _sentinel = Object();
class Community {
final String id;
final String name;
final String relayUrl;
final String? pubkey;
final String? nsec;
final DateTime addedAt;
const Community({
required this.id,
required this.name,
required this.relayUrl,
this.pubkey,
this.nsec,
required this.addedAt,
});
factory Community.create({
required String name,
required String relayUrl,
String? pubkey,
String? nsec,
}) {
return Community(
id: _uuid.v4(),
name: name,
relayUrl: relayUrl,
pubkey: pubkey,
nsec: nsec,
addedAt: DateTime.now(),
);
}
Community copyWith({
String? name,
String? relayUrl,
Object? pubkey = _sentinel,
Object? nsec = _sentinel,
}) {
return Community(
id: id,
name: name ?? this.name,
relayUrl: relayUrl ?? this.relayUrl,
pubkey: pubkey == _sentinel ? this.pubkey : pubkey as String?,
nsec: nsec == _sentinel ? this.nsec : nsec as String?,
addedAt: addedAt,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'relayUrl': relayUrl,
if (pubkey != null) 'pubkey': pubkey,
if (nsec != null) 'nsec': nsec,
'addedAt': addedAt.toIso8601String(),
};
factory Community.fromJson(Map<String, dynamic> json) => Community(
id: json['id'] as String,
name: json['name'] as String,
relayUrl: json['relayUrl'] as String,
pubkey: json['pubkey'] as String?,
nsec: json['nsec'] as String?,
addedAt: DateTime.parse(json['addedAt'] as String),
);
/// Derive a human-friendly community name from a relay URL.
static String nameFromUrl(String url) {
try {
final host = Uri.parse(url).host;
if (host.contains('localhost') || host == '127.0.0.1') return 'Local Dev';
final parts = host.split('.');
if (parts.length > 2) return parts.first;
return host;
} catch (_) {
return 'Community';
}
}
}
@@ -0,0 +1,59 @@
import 'dart:convert';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
/// Supplies the HTTP client used for NIP-11 community icon lookups.
///
/// Tests can override this provider to return deterministic relay responses.
final communityIconHttpClientProvider = Provider<http.Client>((ref) {
final client = http.Client();
ref.onDispose(client.close);
return client;
});
/// Reads a community's icon from its public NIP-11 relay information document.
///
/// The lookup does not depend on the active relay session, so icons can render
/// for every paired community in the switcher. Callers explicitly invalidate
/// this family when opening the switcher so transient failures and relay
/// metadata updates can be retried without background polling.
final communityIconProvider = FutureProvider.autoDispose
.family<String?, String>((ref, relayUrl) async {
final uri = _relayInfoUri(relayUrl);
if (uri == null) return null;
try {
final response = await ref
.read(communityIconHttpClientProvider)
.get(uri, headers: const {'Accept': 'application/nostr+json'})
.timeout(const Duration(seconds: 5));
if (response.statusCode < 200 || response.statusCode >= 300) {
return null;
}
final document = jsonDecode(response.body);
if (document is! Map<String, dynamic>) return null;
final icon = document['icon'];
if (icon is! String || icon.trim().isEmpty) return null;
return icon.trim();
} catch (_) {
return null;
}
});
Uri? _relayInfoUri(String relayUrl) {
try {
final uri = Uri.parse(relayUrl.trim());
final scheme = switch (uri.scheme) {
'wss' => 'https',
'ws' => 'http',
'https' || 'http' => uri.scheme,
_ => null,
};
return scheme == null ? null : uri.replace(scheme: scheme);
} on FormatException {
return null;
}
}
@@ -0,0 +1,127 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../auth/auth_provider.dart';
import 'community.dart';
import 'community_storage.dart';
final communityStorageProvider = Provider<CommunityStorage>((ref) {
return CommunityStorage();
});
class CommunityListNotifier extends AsyncNotifier<List<Community>> {
@override
Future<List<Community>> build() async {
final storage = ref.read(communityStorageProvider);
return storage.loadAll();
}
/// Add a community. If one with the same relay URL already exists, update
/// its credentials instead. Returns the effective community ID.
Future<String> addCommunity(Community community) async {
final storage = ref.read(communityStorageProvider);
final current = state.value ?? [];
// If a community with the same relay URL exists, update its credentials
// instead of creating a duplicate entry.
final existingIndex = current.indexWhere(
(w) => w.relayUrl == community.relayUrl,
);
if (existingIndex >= 0) {
final existing = current[existingIndex];
final updated = existing.copyWith(
pubkey: community.pubkey,
nsec: community.nsec,
);
await storage.save(updated);
final updatedList = [...current];
updatedList[existingIndex] = updated;
state = AsyncData(updatedList);
return existing.id;
}
await storage.save(community);
state = AsyncData([...current, community]);
return community.id;
}
Future<void> removeCommunity(String id) async {
final storage = ref.read(communityStorageProvider);
await storage.remove(id);
final current = state.value ?? [];
state = AsyncData(current.where((w) => w.id != id).toList());
// If we removed the active community, switch to another or sign out.
final activeId = await storage.loadActiveId();
if (activeId == id) {
final remaining = state.value ?? [];
if (remaining.isNotEmpty) {
await switchCommunity(remaining.first.id);
} else {
await storage.clearActiveId();
// Invalidate auth so it re-evaluates against the now-empty storage
// and transitions to unauthenticated.
ref.invalidate(authProvider);
}
}
}
Future<void> switchCommunity(String id) async {
final storage = ref.read(communityStorageProvider);
await storage.saveActiveId(id);
// Reassign list state to trigger activeCommunityProvider (which watches
// communityListProvider.future) to rebuild and pick up the new active ID.
// We can't use ref.invalidate(activeCommunityProvider) here because that
// creates a circular dependency — activeCommunityProvider watches us.
state = AsyncData([...state.value ?? []]);
// Invalidate auth so AuthState.community reflects the new active community.
ref.invalidate(authProvider);
}
Future<void> renameCommunity(String id, String name) async {
final storage = ref.read(communityStorageProvider);
final current = state.value ?? [];
final index = current.indexWhere((w) => w.id == id);
if (index < 0) return;
final updated = current[index].copyWith(name: name);
await storage.save(updated);
final updatedList = [...current];
updatedList[index] = updated;
state = AsyncData(updatedList);
}
}
final communityListProvider =
AsyncNotifierProvider<CommunityListNotifier, List<Community>>(
CommunityListNotifier.new,
);
/// The currently active community, derived from the stored active ID and
/// the community list.
final activeCommunityProvider = FutureProvider<Community?>((ref) async {
final communities = await ref.watch(communityListProvider.future);
final storage = ref.read(communityStorageProvider);
final activeId = await storage.loadActiveId();
if (communities.isEmpty) return null;
if (activeId == null) {
// No active ID stored but communities exist — fall back to first.
await storage.saveActiveId(communities.first.id);
return communities.first;
}
try {
return communities.firstWhere((w) => w.id == activeId);
} on StateError {
// Active ID points to a community that no longer exists.
// Fall back to first community.
if (communities.isNotEmpty) {
await storage.saveActiveId(communities.first.id);
return communities.first;
}
return null;
}
});
@@ -0,0 +1,111 @@
import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'community.dart';
class CommunityStorage {
static const _keyCommunities = 'buzz_communities';
static const _keyActiveId = 'buzz_active_community_id';
// Legacy keys for migration.
static const _legacyCommunities = 'buzz_workspaces';
static const _legacyActiveId = 'buzz_active_workspace_id';
static const _legacyRelayUrl = 'buzz_relay_url';
static const _legacyToken = 'buzz_token';
static const _legacyPubkey = 'buzz_pubkey';
static const _legacyNsec = 'buzz_nsec';
final FlutterSecureStorage _secure;
CommunityStorage({FlutterSecureStorage? secure})
: _secure = secure ?? const FlutterSecureStorage();
/// Load all communities. On first call, migrates legacy single-community
/// credentials if present.
Future<List<Community>> loadAll() async {
final raw = await _secure.read(key: _keyCommunities);
if (raw != null) return _decodeList(raw);
final legacyCommunities = await _secure.read(key: _legacyCommunities);
if (legacyCommunities != null) {
final communities = _decodeList(legacyCommunities);
await _saveList(communities);
final legacyActiveId = await _secure.read(key: _legacyActiveId);
if (legacyActiveId != null) await saveActiveId(legacyActiveId);
await _secure.delete(key: _legacyCommunities);
await _secure.delete(key: _legacyActiveId);
return communities;
}
// Migration: check for legacy single-community keys.
final legacyUrl = await _secure.read(key: _legacyRelayUrl);
final legacyToken = await _secure.read(key: _legacyToken);
if (legacyUrl != null && legacyToken != null) {
final legacyPubkey = await _secure.read(key: _legacyPubkey);
final legacyNsec = await _secure.read(key: _legacyNsec);
final name = Community.nameFromUrl(legacyUrl);
final community = Community.create(
name: name,
relayUrl: legacyUrl,
pubkey: legacyPubkey,
nsec: legacyNsec,
);
await _saveList([community]);
await saveActiveId(community.id);
// Delete legacy keys.
await _secure.delete(key: _legacyRelayUrl);
await _secure.delete(key: _legacyToken);
await _secure.delete(key: _legacyPubkey);
await _secure.delete(key: _legacyNsec);
return [community];
}
return [];
}
Future<void> save(Community community) async {
final all = await loadAll();
final index = all.indexWhere((w) => w.id == community.id);
if (index >= 0) {
all[index] = community;
} else {
all.add(community);
}
await _saveList(all);
}
Future<void> remove(String id) async {
final all = await loadAll();
all.removeWhere((w) => w.id == id);
await _saveList(all);
}
Future<String?> loadActiveId() async {
return _secure.read(key: _keyActiveId);
}
Future<void> saveActiveId(String id) async {
await _secure.write(key: _keyActiveId, value: id);
}
Future<void> clearActiveId() async {
await _secure.delete(key: _keyActiveId);
}
List<Community> _decodeList(String raw) {
final list = jsonDecode(raw) as List<dynamic>;
return list
.map((entry) => Community.fromJson(entry as Map<String, dynamic>))
.toList();
}
Future<void> _saveList(List<Community> communities) async {
final json = jsonEncode(communities.map((item) => item.toJson()).toList());
await _secure.write(key: _keyCommunities, value: json);
}
}
+65
View File
@@ -0,0 +1,65 @@
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:pointycastle/ecc/curves/secp256k1.dart';
/// Compute secp256k1 ECDH shared secret (raw x-coordinate, unhashed).
///
/// Both [privateKeyHex] and [publicKeyHex] are 64-char lowercase hex strings
/// (32 bytes each). The public key is an x-only BIP-340 key.
///
/// Returns the 32-byte x-coordinate of the shared point.
Uint8List ecdhSharedSecret(String privateKeyHex, String publicKeyHex) {
final params = ECCurve_secp256k1();
// Parse private key scalar.
final d = BigInt.parse(privateKeyHex, radix: 16);
// Lift x-only public key to a full curve point (assume even y / prefix 02).
final xBytes = hexToBytes(publicKeyHex);
final compressed = Uint8List(33);
compressed[0] = 0x02; // even y
compressed.setRange(1, 33, xBytes);
final pubPoint = params.curve.decodePoint(compressed);
if (pubPoint == null) {
throw ArgumentError('Invalid public key: cannot decode point');
}
// Scalar multiplication: shared = d * Q
final shared = pubPoint * d;
if (shared == null || shared.isInfinity) {
throw StateError('ECDH produced point at infinity');
}
// Extract 32-byte x-coordinate (big-endian, zero-padded).
return bigIntTo32Bytes(shared.x!.toBigInteger()!);
}
/// Generate secure random bytes.
Uint8List secureRandomBytes(int length) {
final rng = math.Random.secure();
final data = Uint8List(length);
for (var i = 0; i < length; i++) {
data[i] = rng.nextInt(256);
}
return data;
}
// ── Hex helpers (shared across crypto modules) ──────────────────────────────
Uint8List hexToBytes(String hex) {
final result = Uint8List(hex.length ~/ 2);
for (var i = 0; i < result.length; i++) {
result[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16);
}
return result;
}
String bytesToHex(Uint8List bytes) {
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
}
Uint8List bigIntTo32Bytes(BigInt value) {
final hexStr = value.toRadixString(16).padLeft(64, '0');
return hexToBytes(hexStr);
}
+44
View File
@@ -0,0 +1,44 @@
import 'dart:typed_data';
import 'package:pointycastle/api.dart';
import 'package:pointycastle/digests/sha256.dart';
import 'package:pointycastle/macs/hmac.dart';
/// HKDF-SHA256 Extract: PRK = HMAC-SHA256(salt, ikm).
Uint8List hkdfExtract(Uint8List salt, Uint8List ikm) {
final hmac = HMac(SHA256Digest(), 64);
hmac.init(KeyParameter(salt.isEmpty ? Uint8List(32) : salt));
final out = Uint8List(32);
hmac.update(ikm, 0, ikm.length);
hmac.doFinal(out, 0);
return out;
}
/// HKDF-SHA256 Expand: OKM = HKDF-Expand(prk, info, length).
Uint8List hkdfExpand(Uint8List prk, Uint8List info, int length) {
final hmac = HMac(SHA256Digest(), 64);
hmac.init(KeyParameter(prk));
final n = (length + 31) ~/ 32; // ceil(length / hashLen)
final okm = Uint8List(n * 32);
var prev = Uint8List(0);
for (var i = 1; i <= n; i++) {
hmac.reset();
hmac.update(prev, 0, prev.length);
hmac.update(info, 0, info.length);
final counter = Uint8List.fromList([i]);
hmac.update(counter, 0, 1);
prev = Uint8List(32);
hmac.doFinal(prev, 0);
okm.setRange((i - 1) * 32, i * 32, prev);
}
return Uint8List.sublistView(okm, 0, length);
}
/// Convenience: HKDF-SHA256(ikm, salt, info, length).
Uint8List hkdf(Uint8List ikm, Uint8List salt, Uint8List info, int length) {
final prk = hkdfExtract(salt, ikm);
return hkdfExpand(prk, info, length);
}
+169
View File
@@ -0,0 +1,169 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:pointycastle/api.dart';
import 'package:pointycastle/digests/sha256.dart';
import 'package:pointycastle/macs/hmac.dart';
import 'package:pointycastle/stream/chacha7539.dart';
import 'ecdh.dart';
import 'hkdf.dart';
/// NIP-44 v2 conversation key derivation.
///
/// conversation_key = HKDF-Extract(salt="nip44-v2", ikm=ecdh_shared_secret)
Uint8List getConversationKey(String senderPrivHex, String receiverPubHex) {
final shared = ecdhSharedSecret(senderPrivHex, receiverPubHex);
final salt = utf8.encode('nip44-v2');
return hkdfExtract(Uint8List.fromList(salt), shared);
}
/// NIP-44 v2 encrypt.
///
/// Returns base64-encoded payload: version(1) || nonce(32) || ciphertext || mac(32)
String nip44Encrypt(Uint8List conversationKey, String plaintext) {
final plaintextBytes = utf8.encode(plaintext);
if (plaintextBytes.isEmpty || plaintextBytes.length > 65535) {
throw ArgumentError('Plaintext must be 1-65535 bytes');
}
final padded = _pad(Uint8List.fromList(plaintextBytes));
final nonce = secureRandomBytes(32);
// Derive message keys: chacha_key(32) + chacha_nonce(12) + hmac_key(32) = 76
final messageKeys = hkdfExpand(conversationKey, nonce, 76);
final chachaKey = Uint8List.sublistView(messageKeys, 0, 32);
final chachaNonce = Uint8List.sublistView(messageKeys, 32, 44);
final hmacKey = Uint8List.sublistView(messageKeys, 44, 76);
final ciphertext = _chacha20(chachaKey, chachaNonce, padded);
// HMAC-SHA256 over nonce || ciphertext.
final mac = _hmacSha256(hmacKey, _concat([nonce, ciphertext]));
// Assemble: version(0x02) || nonce(32) || ciphertext || mac(32)
final payload = _concat([
Uint8List.fromList([0x02]),
nonce,
ciphertext,
mac,
]);
return base64.encode(payload);
}
/// NIP-44 v2 decrypt.
///
/// Takes base64-encoded payload, returns plaintext string.
String nip44Decrypt(Uint8List conversationKey, String payloadBase64) {
final payload = base64.decode(payloadBase64);
// Minimum: version(1) + nonce(32) + min_ciphertext(32) + mac(32) = 97
if (payload.length < 97) {
throw FormatException('NIP-44 payload too short: ${payload.length}');
}
if (payload[0] != 0x02) {
throw FormatException('NIP-44 unsupported version: ${payload[0]}');
}
final nonce = Uint8List.sublistView(payload, 1, 33);
final ciphertext = Uint8List.sublistView(payload, 33, payload.length - 32);
final receivedMac = Uint8List.sublistView(payload, payload.length - 32);
final messageKeys = hkdfExpand(conversationKey, nonce, 76);
final chachaKey = Uint8List.sublistView(messageKeys, 0, 32);
final chachaNonce = Uint8List.sublistView(messageKeys, 32, 44);
final hmacKey = Uint8List.sublistView(messageKeys, 44, 76);
// Verify HMAC.
final expectedMac = _hmacSha256(hmacKey, _concat([nonce, ciphertext]));
if (!constantTimeEquals(receivedMac, expectedMac)) {
throw FormatException('NIP-44 HMAC verification failed');
}
final padded = _chacha20(chachaKey, chachaNonce, ciphertext);
return _unpad(padded);
}
// ── Padding ─────────────────────────────────────────────────────────────────
int _calcPaddedLen(int unpaddedLen) {
if (unpaddedLen <= 0) throw ArgumentError('Length must be > 0');
if (unpaddedLen <= 32) return 32;
final nextPower = 1 << (_log2(unpaddedLen - 1) + 1);
final chunk = nextPower <= 256 ? 32 : nextPower ~/ 8;
return chunk * (((unpaddedLen - 1) ~/ chunk) + 1);
}
int _log2(int x) {
var result = 0;
while ((1 << (result + 1)) <= x) {
result++;
}
return result;
}
Uint8List _pad(Uint8List plaintext) {
final len = plaintext.length;
final paddedLen = _calcPaddedLen(len);
// Result: [2-byte BE length][plaintext][zero padding]
final result = Uint8List(2 + paddedLen);
result[0] = (len >> 8) & 0xFF;
result[1] = len & 0xFF;
result.setRange(2, 2 + len, plaintext);
return result;
}
String _unpad(Uint8List padded) {
if (padded.length < 2) throw FormatException('Padded data too short');
final len = (padded[0] << 8) | padded[1];
if (len == 0 || 2 + len > padded.length) {
throw FormatException('Invalid padding length: $len');
}
return utf8.decode(padded.sublist(2, 2 + len));
}
// ── ChaCha20 (IETF, 12-byte nonce) ─────────────────────────────────────────
Uint8List _chacha20(Uint8List key, Uint8List nonce, Uint8List data) {
final cipher = ChaCha7539Engine();
cipher.init(false, ParametersWithIV(KeyParameter(key), nonce));
return cipher.process(data);
}
// ── HMAC-SHA256 ─────────────────────────────────────────────────────────────
Uint8List _hmacSha256(Uint8List key, Uint8List data) {
final hmac = HMac(SHA256Digest(), 64);
hmac.init(KeyParameter(key));
hmac.update(data, 0, data.length);
final out = Uint8List(32);
hmac.doFinal(out, 0);
return out;
}
// ── Helpers ─────────────────────────────────────────────────────────────────
Uint8List _concat(List<Uint8List> parts) {
final totalLen = parts.fold<int>(0, (sum, p) => sum + p.length);
final result = Uint8List(totalLen);
var offset = 0;
for (final part in parts) {
result.setRange(offset, offset + part.length, part);
offset += part.length;
}
return result;
}
/// Constant-time byte comparison to prevent timing side-channels.
bool constantTimeEquals(Uint8List a, Uint8List b) {
if (a.length != b.length) return false;
var result = 0;
for (var i = 0; i < a.length; i++) {
result |= a[i] ^ b[i];
}
return result == 0;
}
+74
View File
@@ -0,0 +1,74 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:nostr/nostr.dart' as nostr;
import 'package:pointycastle/digests/sha256.dart';
/// NIP-OA (Owner Attestation) — verify the `auth` tag on a kind:0 profile
/// that proves an owner key authorized an agent key.
///
/// Tag format: ["auth", "<owner-pubkey-hex>", "<conditions>", "<sig-hex>"]
/// Preimage: "nostr:agent-auth:" + agent_pubkey_hex + ":" + conditions
/// Signature: BIP-340 Schnorr over SHA256(preimage) by the owner key.
///
/// Mirrors `profile_valid_oa_owner_pubkey` in desktop/src-tauri: the tag is
/// verified against the profile event author, so a forged or stale marker
/// cannot turn a person into an agent.
///
/// Returns the owner pubkey (lowercase hex) for the first valid auth tag,
/// or null if none verifies.
String? verifiedOaOwnerPubkey(List<List<String>> tags, String agentPubkey) {
final agent = agentPubkey.toLowerCase();
for (final tag in tags) {
if (tag.length != 4 || tag[0] != 'auth') continue;
final owner = tag[1].toLowerCase();
final conditions = tag[2];
final sig = tag[3];
// Self-attestation is meaningless and rejected.
if (owner == agent) continue;
if (owner.length != 64 || sig.length != 128) continue;
if (!_validConditions(conditions)) continue;
final preimage = utf8.encode('nostr:agent-auth:$agent:$conditions');
final digest = SHA256Digest().process(Uint8List.fromList(preimage));
final message = digest
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
try {
if (nostr.Schnorr.verify(
publicKey: owner,
message: message,
signature: sig,
)) {
return owner;
}
} catch (_) {
// Malformed hex — treat as an invalid tag.
}
}
return null;
}
/// Validate the NIP-OA `conditions` string: empty, or `&`-joined clauses of
/// `kind=<n>`, `created_at<<n>`, or `created_at><n>` with canonical decimals.
bool _validConditions(String conditions) {
if (conditions.isEmpty) return true;
if (conditions.contains(RegExp(r'\s'))) return false;
for (final clause in conditions.split('&')) {
final match = RegExp(
r'^(?:kind=|created_at<|created_at>)(0|[1-9][0-9]*)$',
).firstMatch(clause);
if (match == null) return false;
final value = int.tryParse(match.group(1)!);
if (value == null || value > 4294967295) return false;
if (clause.startsWith('kind=') && value > 65535) return false;
}
return true;
}
@@ -0,0 +1,139 @@
import 'package:flutter/foundation.dart';
import '../relay/nostr_models.dart';
/// A community custom emoji: a `:shortcode:` mapped to an image URL.
///
/// Mirrors desktop's NIP-30 model (kind:30030, per-user sets unioned into one
/// community palette). Identity downstream is shortcode-only — the palette must
/// never carry two URLs under one shortcode.
@immutable
class CustomEmoji {
final String shortcode;
final String url;
const CustomEmoji({required this.shortcode, required this.url});
@override
bool operator ==(Object other) =>
other is CustomEmoji && other.shortcode == shortcode && other.url == url;
@override
int get hashCode => Object.hash(shortcode, url);
@override
String toString() => 'CustomEmoji($shortcode -> $url)';
}
/// NIP-30 emoji set kind (parameterized-replaceable).
const int kindEmojiSet = 30030;
/// d-tag for a member's own custom emoji set.
const String customEmojiSetDTag = 'buzz:custom-emoji';
final RegExp _shortcodeRe = RegExp(r'^[a-z0-9_-]+$');
/// Normalize a shortcode the way the relay does: strip surrounding colons and
/// lowercase. Returns null if the result is empty or has invalid chars.
String? normalizeShortcode(String raw) {
final stripped = raw
.trim()
.replaceFirst(RegExp(r'^:+'), '')
.replaceFirst(RegExp(r':+$'), '');
final lower = stripped.toLowerCase();
return _shortcodeRe.hasMatch(lower) ? lower : null;
}
/// Parse NIP-30 `["emoji", shortcode, url]` tags from one event's tags into a
/// custom-emoji list. Shortcodes are normalized; malformed/duplicate entries
/// within the one event are skipped (first wins).
List<CustomEmoji> customEmojiFromTags(List<List<String>> tags) {
final seen = <String>{};
final emoji = <CustomEmoji>[];
for (final tag in tags) {
if (tag.isEmpty || tag[0] != 'emoji') continue;
if (tag.length < 3) continue;
final rawShortcode = tag[1];
final url = tag[2];
if (rawShortcode.isEmpty || url.isEmpty) continue;
final shortcode = normalizeShortcode(rawShortcode);
if (shortcode == null) continue;
if (seen.contains(shortcode)) continue;
seen.add(shortcode);
emoji.add(CustomEmoji(shortcode: shortcode, url: url));
}
return emoji;
}
/// Union every member's kind:30030 set into the community palette, collapsed to
/// one entry per shortcode. When members disagree on a shortcode's URL, the
/// most recently published set wins (`created_at` is signed event data, so the
/// result stays deterministic and fetch-order-independent); equal timestamps
/// tie-break to the lexicographically-smallest URL. Output is sorted by
/// shortcode.
List<CustomEmoji> unionCustomEmoji(Iterable<NostrEvent> events) {
final byShortcode = <String, ({String url, int createdAt})>{};
for (final event in events) {
for (final e in customEmojiFromTags(event.tags)) {
final winner = byShortcode[e.shortcode];
if (winner == null ||
event.createdAt > winner.createdAt ||
(event.createdAt == winner.createdAt &&
e.url.compareTo(winner.url) < 0)) {
byShortcode[e.shortcode] = (url: e.url, createdAt: event.createdAt);
}
}
}
final result =
byShortcode.entries
.map((e) => CustomEmoji(shortcode: e.key, url: e.value.url))
.toList()
..sort((a, b) => a.shortcode.compareTo(b.shortcode));
return result;
}
final RegExp _shortcodeScan = RegExp(r':([a-z0-9_-]+):', caseSensitive: false);
/// Return one `["emoji", shortcode, url]` tag per *distinct* known custom emoji
/// referenced in [content]. Matched case-insensitively; the canonical lowercase
/// shortcode is emitted. Unknown `:foo:` are ignored. Order follows first
/// appearance; each shortcode emitted at most once.
///
/// Mirrors how @mentions become `p` tags: derived from the final content at
/// send time so the event is self-contained for any NIP-30 client.
List<List<String>> buildCustomEmojiTags(
String content,
List<CustomEmoji> customEmoji,
) {
if (customEmoji.isEmpty) return [];
final urlByShortcode = {for (final e in customEmoji) e.shortcode: e.url};
final emitted = <String>{};
final tags = <List<String>>[];
for (final match in _shortcodeScan.allMatches(content)) {
final shortcode = match.group(1)!.toLowerCase();
if (emitted.contains(shortcode)) continue;
final url = urlByShortcode[shortcode];
if (url == null) continue;
emitted.add(shortcode);
tags.add(['emoji', shortcode, url]);
}
return tags;
}
/// Resolve the image URL for a reaction whose content is a custom-emoji
/// `:shortcode:`, from the community palette. Returns null for unicode
/// reactions or unknown shortcodes.
String? reactionEmojiUrl(String emoji, List<CustomEmoji>? palette) {
if (palette == null) return null;
if (!emoji.startsWith(':') || !emoji.endsWith(':')) return null;
final shortcode = emoji.substring(1, emoji.length - 1).toLowerCase();
for (final e in palette) {
if (e.shortcode == shortcode) return e.url;
}
return null;
}
@@ -0,0 +1,60 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../relay/relay.dart';
import 'custom_emoji.dart';
/// Community custom-emoji palette (NIP-30, per-user kind:30030 sets unioned).
///
/// On build, fetches every member's set and collapses to one entry per
/// shortcode (see [unionCustomEmoji]). Re-fetches when the relay session
/// reconnects. The palette is the single source of truth consumed by the
/// renderer, picker, autocomplete, and reaction/send tag plumbing.
class CustomEmojiPaletteNotifier extends AsyncNotifier<List<CustomEmoji>> {
@override
Future<List<CustomEmoji>> build() {
ref.watch(relayClientProvider);
ref.watch(relaySessionProvider);
return _fetch();
}
Future<List<CustomEmoji>> _fetch() async {
final sessionState = ref.read(relaySessionProvider);
if (sessionState.status != SessionStatus.connected) return [];
try {
final session = ref.read(relaySessionProvider.notifier);
final events = await session.fetchHistory(
const NostrFilter(
kinds: [kindEmojiSet],
tags: {
'#d': [customEmojiSetDTag],
},
// One 30030 per member; the relay keeps only the latest per
// (pubkey, d_tag), so this bounds member count, not history.
limit: 500,
),
);
return unionCustomEmoji(events);
} catch (_) {
return [];
}
}
/// Force a re-fetch of the community palette.
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(_fetch);
}
}
final customEmojiPaletteProvider =
AsyncNotifierProvider<CustomEmojiPaletteNotifier, List<CustomEmoji>>(
CustomEmojiPaletteNotifier.new,
);
/// Synchronous read of the current palette (empty while loading/error).
/// Convenience for widgets that only need the resolved list, not the async
/// state — renderer, autocomplete, reaction resolution.
final customEmojiListProvider = Provider<List<CustomEmoji>>((ref) {
return ref.watch(customEmojiPaletteProvider).value ?? const [];
});
@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import 'package:gpt_markdown/gpt_markdown.dart';
import 'package:gpt_markdown/custom_widgets/markdown_config.dart';
import '../relay/relay.dart';
import 'custom_emoji.dart';
/// Default rendered height of an inline custom emoji, in logical pixels.
/// Roughly matches a line of body text so emoji sit on the baseline cleanly.
const double kCustomEmojiInlineSize = 20.0;
/// A single custom-emoji image rendered as a square, network-loaded glyph.
///
/// Reused everywhere a `:shortcode:` resolves to an image: inline in message
/// bodies, reaction pills, status, and the picker. Falls back to the literal
/// `:shortcode:` text if the image fails to load, so a broken URL never leaves
/// a blank gap.
class CustomEmojiImage extends StatelessWidget {
final String shortcode;
final String url;
final double size;
const CustomEmojiImage({
super.key,
required this.shortcode,
required this.url,
this.size = kCustomEmojiInlineSize,
});
@override
Widget build(BuildContext context) {
final fallbackStyle = DefaultTextStyle.of(context).style;
return MediaImage(
url: url,
width: size,
height: size,
decodeWidth: size,
fit: BoxFit.contain,
filterQuality: FilterQuality.medium,
semanticLabel: ':$shortcode:',
errorBuilder: (_, _, _) => Text(':$shortcode:', style: fallbackStyle),
);
}
}
/// gpt_markdown inline component that replaces `:shortcode:` with an inline
/// [CustomEmojiImage] for *known* shortcodes only. Unknown `:foo:` is left as
/// plain text. Matched case-insensitively; resolved via the lowercase palette.
///
/// Parallel to the `_MentionMd` / `_ChannelLinkMd` components in
/// message_content.dart — add an instance to a `GptMarkdown.inlineComponents`
/// list (before the default components) to enable custom emoji in any markdown
/// surface.
class CustomEmojiMd extends InlineMd {
final Map<String, String> _urlByShortcode;
final double size;
late final RegExp _exp = _buildPattern(_urlByShortcode.keys);
CustomEmojiMd(List<CustomEmoji> palette, {this.size = kCustomEmojiInlineSize})
: _urlByShortcode = {for (final e in palette) e.shortcode: e.url};
@override
RegExp get exp => _exp;
@override
InlineSpan span(BuildContext context, String text, GptMarkdownConfig config) {
final raw = exp.firstMatch(text.trim())?.group(0);
if (raw == null) {
return TextSpan(text: text, style: config.style);
}
final shortcode = raw.substring(1, raw.length - 1).toLowerCase();
final url = _urlByShortcode[shortcode];
if (url == null) {
return TextSpan(text: text, style: config.style);
}
return WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: CustomEmojiImage(shortcode: shortcode, url: url, size: size),
);
}
/// Build a regex matching `:shortcode:` for any known shortcode, longest
/// first so a longer name isn't shadowed by a shorter prefix. Matches nothing
/// when the palette is empty (a regex that can never match).
static RegExp _buildPattern(Iterable<String> shortcodes) {
final sorted = shortcodes.where((s) => s.trim().isNotEmpty).toSet().toList()
..sort((a, b) => b.length.compareTo(a.length));
if (sorted.isEmpty) {
// Never matches — gpt_markdown skips this component entirely.
return RegExp(r'(?!x)x');
}
final alternatives = sorted.map(RegExp.escape).join('|');
return RegExp(':(?:$alternatives):', caseSensitive: false);
}
}
+221
View File
@@ -0,0 +1,221 @@
/// Parsing for `buzz://` deep links.
///
/// Mirrors the desktop handler in `desktop/src-tauri/src/deep_link.rs`:
/// `buzz://message?channel=<uuid>&id=<hex>[&thread=<hex>]` references a
/// message (optionally inside a thread) in a channel. Required params that
/// are missing or empty make the link invalid — the caller never sees a
/// half-formed target.
library;
import '../relay/relay_validation.dart';
/// A parsed deep link supported by the app.
sealed class BuzzDeepLink {
const BuzzDeepLink();
}
/// A parsed relay invite link.
///
/// Canonical share links are `https://<relay>/invite/<code>`. The custom
/// `buzz://join?relay=<ws(s)://relay>&code=<code>` form is only an installed-app
/// handoff from the web landing page.
class InviteDeepLink extends BuzzDeepLink {
/// Relay URL normalized to the websocket scheme used by the app.
final String relayUrl;
/// Invite code from the link.
final String code;
/// Optional receipt proving acceptance of the relay's current join policy.
final String? policyReceipt;
const InviteDeepLink({
required this.relayUrl,
required this.code,
this.policyReceipt,
});
@override
bool operator ==(Object other) =>
other is InviteDeepLink &&
other.relayUrl == relayUrl &&
other.code == code &&
other.policyReceipt == policyReceipt;
@override
int get hashCode => Object.hash(relayUrl, code, policyReceipt);
@override
String toString() =>
'InviteDeepLink(relay: $relayUrl, code: $code, policyReceipt: $policyReceipt)';
}
/// A parsed `buzz://message` deep link.
class MessageDeepLink extends BuzzDeepLink {
/// Channel UUID from the `channel` query param.
final String channelId;
/// Event ID (hex) from the `id` query param.
final String messageId;
/// Optional thread root event ID from the `thread` query param.
final String? threadRootId;
const MessageDeepLink({
required this.channelId,
required this.messageId,
this.threadRootId,
});
@override
bool operator ==(Object other) =>
other is MessageDeepLink &&
other.channelId == channelId &&
other.messageId == messageId &&
other.threadRootId == threadRootId;
@override
int get hashCode => Object.hash(channelId, messageId, threadRootId);
@override
String toString() =>
'MessageDeepLink(channel: $channelId, id: $messageId, '
'thread: $threadRootId)';
}
/// Build a canonical `buzz://message` link for a channel message.
///
/// Mirrors `desktop/src/features/messages/lib/messageLink.ts` so links copied
/// or shared from mobile round-trip through every client's parser:
/// `buzz://message?channel=<uuid>&id=<eventId>[&thread=<rootId>]`.
///
/// An empty [threadRootId] is treated as "no thread" so callers can pass
/// through a nullable thread reference without extra checks.
String buildMessageLink({
required String channelId,
required String messageId,
String? threadRootId,
}) {
if (channelId.isEmpty) {
throw ArgumentError('buildMessageLink: channelId is required');
}
if (messageId.isEmpty) {
throw ArgumentError('buildMessageLink: messageId is required');
}
final params = <String, String>{
'channel': channelId,
'id': messageId,
if (threadRootId != null && threadRootId.isNotEmpty) 'thread': threadRootId,
};
return Uri(
scheme: 'buzz',
host: 'message',
queryParameters: params,
).toString();
}
/// Parse a `buzz://message?…` URI into a [MessageDeepLink].
///
/// Returns `null` for non-`buzz` schemes, non-`message` hosts (e.g.
/// `buzz://connect` which is desktop-only), or links missing a non-empty
/// `channel` or `id` param.
MessageDeepLink? parseMessageDeepLink(Uri uri) {
if (uri.scheme != 'buzz' || uri.host != 'message') return null;
final channel = uri.queryParameters['channel'];
final id = uri.queryParameters['id'];
if (channel == null || channel.isEmpty || id == null || id.isEmpty) {
return null;
}
final thread = uri.queryParameters['thread'];
return MessageDeepLink(
channelId: channel,
messageId: id,
threadRootId: (thread == null || thread.isEmpty) ? null : thread,
);
}
/// Parse canonical HTTPS invite links and `buzz://join` app handoffs.
///
/// Accepted forms:
/// - `https://<relay>/invite/<code>` -> `wss://<relay>` + code
/// - `http://localhost/invite/<code>` -> `ws://localhost` + code in debug builds
/// - `buzz://join?relay=<wss://relay>&code=<code>` -> relay + code
/// - `buzz://join?relay=<ws://localhost>&code=<code>` -> local relay in debug
///
/// Rejects credentials, fragments, missing params, nested relay credentials, and
/// non-invite paths so scanners do not accidentally treat arbitrary URLs as
/// community admission links.
InviteDeepLink? parseInviteDeepLink(Uri uri) {
if (uri.hasFragment || uri.userInfo.isNotEmpty) return null;
if (uri.scheme == 'buzz') {
if (uri.host != 'join') return null;
final relay = uri.queryParameters['relay'];
final code = uri.queryParameters['code'];
if (relay == null || relay.isEmpty || code == null || code.isEmpty) {
return null;
}
final relayUri = Uri.tryParse(relay);
if (relayUri == null ||
(relayUri.scheme != 'ws' && relayUri.scheme != 'wss') ||
relayUri.host.isEmpty ||
relayUri.userInfo.isNotEmpty ||
relayUri.hasFragment) {
return null;
}
try {
validateInviteRelayUri(relayUri);
} on FormatException {
return null;
}
final normalizedRelay = Uri(
scheme: relayUri.scheme,
host: relayUri.host,
port: relayUri.hasPort ? relayUri.port : null,
).toString();
final policyReceipt = uri.queryParameters['policy_receipt'];
return InviteDeepLink(
relayUrl: normalizedRelay,
code: code,
policyReceipt: policyReceipt == null || policyReceipt.isEmpty
? null
: policyReceipt,
);
}
if (uri.scheme == 'https' || uri.scheme == 'http') {
if (uri.host.isEmpty) return null;
final segments = uri.pathSegments;
if (segments.length != 2 ||
segments[0] != 'invite' ||
segments[1].isEmpty) {
return null;
}
final relayScheme = uri.scheme == 'https' ? 'wss' : 'ws';
final relayUri = Uri(
scheme: relayScheme,
host: uri.host,
port: uri.hasPort ? uri.port : null,
);
try {
validateInviteRelayUri(relayUri);
} on FormatException {
return null;
}
final relay = Uri(
scheme: relayScheme,
host: uri.host,
port: uri.hasPort ? uri.port : null,
).toString();
return InviteDeepLink(relayUrl: relay, code: segments[1]);
}
return null;
}
/// Parse any supported Buzz deep link.
BuzzDeepLink? parseBuzzDeepLink(Uri uri) =>
parseInviteDeepLink(uri) ?? parseMessageDeepLink(uri);
@@ -0,0 +1,52 @@
import 'dart:async';
import 'package:app_links/app_links.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'deep_link.dart';
/// Holds the most recent supported deep link that has not been
/// dispatched yet.
///
/// Listens to [AppLinks.uriLinkStream], which delivers both the cold-start
/// link (the URL that launched the app) and links received while running.
/// Navigation cannot always happen the moment a link arrives — the user may
/// not be authenticated yet, or channels may still be loading — so the parsed
/// link is parked here and consumed by the dispatcher once the app is ready.
class PendingDeepLinkNotifier extends Notifier<BuzzDeepLink?> {
@visibleForTesting
static Stream<Uri>? debugUriStreamOverride;
StreamSubscription<Uri>? _subscription;
@override
BuzzDeepLink? build() {
final stream = debugUriStreamOverride ?? AppLinks().uriLinkStream;
_subscription = stream.listen(handleUri);
ref.onDispose(() {
_subscription?.cancel();
_subscription = null;
});
return null;
}
/// Parse and park an incoming URI. Unsupported links are ignored loudly.
@visibleForTesting
void handleUri(Uri uri) {
final link = parseBuzzDeepLink(uri);
if (link == null) {
debugPrint('deep-link: ignoring unsupported link: $uri');
return;
}
state = link;
}
/// Clear the pending link after it has been dispatched (or dropped).
void consume() => state = null;
}
final pendingDeepLinkProvider =
NotifierProvider<PendingDeepLinkNotifier, BuzzDeepLink?>(
PendingDeepLinkNotifier.new,
);
+398
View File
@@ -0,0 +1,398 @@
/// Emoji particle bursts, ported from desktop's `EmojiBurstProvider`
/// (`desktop/src/shared/ui/EmojiBurstProvider.tsx`).
///
/// Desktop paints to a fixed full-window `<canvas>` above everything and steps
/// the particles on `requestAnimationFrame`. Mobile does the same shape:
/// [EmojiBurstOverlay] installs one full-screen [CustomPaint] above the
/// navigator (so bursts survive route pushes and modal sheets), and a [Ticker]
/// steps the same physics.
///
/// The constants below are desktop's, unchanged — the motion is the product
/// decision, and drifting it would make the two clients feel different.
library;
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'positive_emoji.dart';
/// Particles per burst — desktop's `PICKER_PARTICLES_PER_BURST`.
const _particlesPerBurst = 5;
/// Frames a particle lives — desktop's `PICKER_PARTICLE_LIFE_FRAMES`.
const _particleLifeFrames = 108;
/// Desktop's `MAX_ACTIVE`, scaled down: a phone screen holds far fewer
/// particles before it reads as noise, and every one costs a `saveLayer`.
const _maxActiveParticles = 160;
/// Physics runs at a fixed 60Hz step regardless of display refresh rate.
/// Desktop's constants are per-frame deltas on a 60Hz assumption; stepping them
/// once per real frame would run the burst at double speed on a 120Hz panel.
const _stepSeconds = 1 / 60;
/// Cap on catch-up steps per frame so a stalled frame can't spiral.
const _maxStepsPerFrame = 4;
/// Glyph size the cached [TextPainter] is laid out at. Particles scale off this
/// rather than re-laying out text every frame.
const _glyphCachePx = 64.0;
/// The last emoji added as a reaction that is still waiting for its pill to
/// appear on screen.
///
/// Mobile reactions are not optimistic — `addReaction` awaits the relay and the
/// pill only exists once the event echoes back — so a burst fired at pick time
/// would play against a row that has not changed yet. Desktop solves this the
/// same way (`burstEmojiOnRender` in `MessageReactions.tsx`): remember the
/// pending emoji, and let the pill fire the burst from its own position the
/// frame it first renders as reacted-by-me.
@immutable
class PendingReactionBurst {
final String messageId;
final String emoji;
const PendingReactionBurst({required this.messageId, required this.emoji});
@override
bool operator ==(Object other) =>
other is PendingReactionBurst &&
other.messageId == messageId &&
other.emoji == emoji;
@override
int get hashCode => Object.hash(messageId, emoji);
}
/// One slot, not a queue: a burst is a flourish on the reaction you just added,
/// and two adds in flight at once means the newer one is the interesting one.
class PendingReactionBurstNotifier extends Notifier<PendingReactionBurst?> {
@override
PendingReactionBurst? build() => null;
/// Arm a burst for [emoji] on [messageId]. No-op for emoji that don't
/// celebrate, so callers don't have to gate themselves.
void arm(String messageId, String emoji) {
if (!isPositiveEmojiParticle(emoji)) return;
state = PendingReactionBurst(messageId: messageId, emoji: emoji);
}
/// Claim the pending burst if it is [target], returning whether the caller
/// won. The same message can be on screen twice (a thread pushed over its
/// channel), so exactly one pill must fire.
bool claim(PendingReactionBurst target) {
if (state != target) return false;
state = null;
return true;
}
}
final pendingReactionBurstProvider =
NotifierProvider<PendingReactionBurstNotifier, PendingReactionBurst?>(
PendingReactionBurstNotifier.new,
);
class _Particle {
double x;
double y;
double xv;
double yv;
double rotation;
double spin;
double scale;
double opacity;
double life;
final double maxLife;
final String emoji;
final double fontSize;
final double gravity;
_Particle({
required this.x,
required this.y,
required this.xv,
required this.yv,
required this.rotation,
required this.spin,
required this.scale,
required this.opacity,
required this.life,
required this.maxLife,
required this.emoji,
required this.fontSize,
required this.gravity,
});
/// Desktop's `updateParticle`. Returns false once the particle is spent.
bool step() {
life -= 1;
rotation += spin;
yv += gravity;
xv *= 0.965;
yv *= 0.998;
x += xv;
y += yv;
scale += (1 - scale) * 0.28;
final lifeRatio = life / maxLife;
if (lifeRatio < 0.24) {
opacity = max(0, lifeRatio / 0.24);
}
return life > 0 && opacity > 0.02;
}
}
/// Holds the live particles and notifies the painter each step.
///
/// Deliberately not a `Notifier` over a particle list: the particles mutate 60
/// times a second and must never rebuild a widget. Listeners are the painter's
/// repaint signal only.
class EmojiBurstController extends ChangeNotifier {
final List<_Particle> _particles = [];
final Random _random;
/// Set when a spawn arrives so the overlay knows to start its ticker.
VoidCallback? onSpawn;
EmojiBurstController({Random? random}) : _random = random ?? Random();
bool get hasParticles => _particles.isNotEmpty;
/// Spawn a burst of [emoji] centred on [origin] (global coordinates).
/// Mirrors desktop's `spawnPickerEmojiBurst`.
void burst(String emoji, Offset origin) {
final trimmed = emoji.trim();
if (trimmed.isEmpty) return;
if (_particles.length + _particlesPerBurst > _maxActiveParticles) return;
for (var i = 0; i < _particlesPerBurst; i += 1) {
final horizontalDrift = (_random.nextDouble() - 0.5) * 4.4;
final initialLift = 2.1 + _random.nextDouble() * 2.35;
_particles.add(
_Particle(
x: origin.dx,
y: origin.dy,
xv: horizontalDrift,
yv: -initialLift,
rotation: (_random.nextDouble() - 0.5) * 22,
spin: (_random.nextDouble() - 0.5) * 5.2,
scale: 0.25,
opacity: 1,
life: _particleLifeFrames.toDouble(),
maxLife: _particleLifeFrames.toDouble(),
emoji: trimmed,
fontSize: 18 + (_random.nextDouble() * 24).ceilToDouble(),
// Negative: the burst floats up and keeps accelerating upward.
gravity: -(0.018 + _random.nextDouble() * 0.018),
),
);
}
onSpawn?.call();
notifyListeners();
}
/// Advance one fixed step. Returns whether any particles remain.
bool step() {
for (var i = _particles.length - 1; i >= 0; i -= 1) {
if (!_particles[i].step()) {
_particles[i] = _particles.last;
_particles.removeLast();
}
}
notifyListeners();
return _particles.isNotEmpty;
}
void clear() {
if (_particles.isEmpty) return;
_particles.clear();
notifyListeners();
}
@override
void dispose() {
_particles.clear();
super.dispose();
}
}
final emojiBurstControllerProvider = Provider<EmojiBurstController>((ref) {
final controller = EmojiBurstController();
ref.onDispose(controller.dispose);
return controller;
});
/// Fire a burst of [emoji] centred on [context]'s own box.
///
/// The convenience the call sites actually want: they have a build context for
/// the pill or tile that was tapped and no reason to know about global
/// coordinates. Silently does nothing for emoji outside the positive set or
/// when the platform asks for reduced motion, so callers stay simple.
void burstEmojiFromContext(
WidgetRef ref,
BuildContext context,
String emoji, {
bool requirePositive = true,
}) {
if (requirePositive && !isPositiveEmojiParticle(emoji)) return;
if (MediaQuery.maybeDisableAnimationsOf(context) ?? false) return;
final box = context.findRenderObject();
if (box is! RenderBox || !box.hasSize) return;
final origin = box.localToGlobal(box.size.center(Offset.zero));
ref.read(emojiBurstControllerProvider).burst(emoji, origin);
}
/// Full-screen particle layer. Install once, above the navigator, so bursts
/// keep playing over pushed routes and modal sheets.
class EmojiBurstOverlay extends HookConsumerWidget {
final Widget child;
const EmojiBurstOverlay({super.key, required this.child});
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = ref.watch(emojiBurstControllerProvider);
final tickerProvider = useSingleTickerProvider();
// Ticker + leftover time live in refs: stepping physics must not rebuild
// this widget, which wraps the entire app.
final leftover = useRef(0.0);
final lastElapsed = useRef(Duration.zero);
final ticker = useMemoized(() {
late final Ticker created;
created = tickerProvider.createTicker((elapsed) {
final delta = elapsed - lastElapsed.value;
lastElapsed.value = elapsed;
leftover.value += delta.inMicroseconds / Duration.microsecondsPerSecond;
var steps = 0;
var alive = controller.hasParticles;
while (leftover.value >= _stepSeconds && steps < _maxStepsPerFrame) {
leftover.value -= _stepSeconds;
steps += 1;
alive = controller.step();
if (!alive) break;
}
// Don't bank unspent time from a stall — it would fast-forward the
// next burst.
if (steps >= _maxStepsPerFrame) leftover.value = 0;
if (!alive) {
created.stop();
lastElapsed.value = Duration.zero;
leftover.value = 0;
}
});
return created;
}, [tickerProvider, controller]);
useEffect(() => ticker.dispose, [ticker]);
useEffect(() {
void start() {
if (ticker.isActive) return;
lastElapsed.value = Duration.zero;
leftover.value = 0;
ticker.start();
}
controller.onSpawn = start;
if (controller.hasParticles) start();
return () {
if (controller.onSpawn == start) controller.onSpawn = null;
};
}, [ticker, controller]);
return Stack(
children: [
child,
Positioned.fill(
child: IgnorePointer(
child: ExcludeSemantics(
child: RepaintBoundary(
child: CustomPaint(painter: _EmojiBurstPainter(controller)),
),
),
),
),
],
);
}
}
/// Laid-out glyphs, keyed by emoji. Emoji are drawn thousands of times per
/// burst; laying out the text once and scaling the canvas is the whole trick.
final Map<String, TextPainter> _glyphCache = {};
/// Bounded so a chatty channel full of distinct reactions can't grow it
/// without limit. Bursts are visual sugar — evicting the whole cache costs one
/// re-layout per emoji still on screen.
const _glyphCacheLimit = 64;
TextPainter _glyphFor(String emoji) {
final cached = _glyphCache[emoji];
if (cached != null) return cached;
if (_glyphCache.length >= _glyphCacheLimit) _glyphCache.clear();
final painter = TextPainter(
text: TextSpan(
text: emoji,
style: const TextStyle(fontSize: _glyphCachePx),
),
textDirection: TextDirection.ltr,
)..layout();
_glyphCache[emoji] = painter;
return painter;
}
/// Drop the cached glyph layouts. Exposed for tests; the cache is not
/// community- or account-scoped, so nothing else needs to reset it.
@visibleForTesting
void clearEmojiBurstGlyphCache() => _glyphCache.clear();
class _EmojiBurstPainter extends CustomPainter {
final EmojiBurstController controller;
_EmojiBurstPainter(this.controller) : super(repaint: controller);
@override
void paint(Canvas canvas, Size size) {
for (final particle in controller._particles) {
final glyph = _glyphFor(particle.emoji);
final drawSize = particle.fontSize * particle.scale;
final scale = drawSize / _glyphCachePx;
canvas.save();
canvas.translate(particle.x, particle.y);
canvas.rotate(particle.rotation * pi / 180);
canvas.scale(scale);
// Color fonts ignore TextStyle.color, so fade through a layer instead.
if (particle.opacity < 1) {
canvas.saveLayer(
Rect.fromCenter(
center: Offset.zero,
width: glyph.width,
height: glyph.height,
),
Paint()..color = Colors.black.withValues(alpha: particle.opacity),
);
}
glyph.paint(canvas, Offset(-glyph.width / 2, -glyph.height / 2));
if (particle.opacity < 1) canvas.restore();
canvas.restore();
}
}
@override
bool shouldRepaint(_EmojiBurstPainter oldDelegate) =>
oldDelegate.controller != controller;
}
+154
View File
@@ -0,0 +1,154 @@
import 'package:flutter/foundation.dart';
/// The standard (Unicode) emoji set, projected from the same emoji-mart dataset
/// desktop uses.
///
/// The asset at `assets/emoji/emoji-data.json` is generated by
/// `just mobile-emoji-data`; ids here are emoji-mart ids, which are exactly the
/// `:shortcode:` values desktop emits from its picker and resolves in
/// `emojiDisplayName`. Keeping both clients on one dataset is what stops a
/// shortcode from meaning different things on mobile and desktop.
@immutable
class EmojiEntry {
/// emoji-mart id, i.e. the shortcode without the surrounding colons.
final String id;
/// Human-readable name, e.g. `Index Pointing Up`.
final String name;
/// Search keywords from the dataset.
final List<String> keywords;
/// The default-skin glyph.
final String native;
/// Position within emoji-mart's skin list. The default skin is zero.
final int skinIndex;
/// Owning category id, e.g. `people`.
final String categoryId;
const EmojiEntry({
required this.id,
required this.name,
required this.keywords,
required this.native,
required this.categoryId,
this.skinIndex = 0,
});
/// Unique tile id while preserving the desktop-identical shortcode for the
/// default skin.
String get tileId => skinIndex == 0 ? id : '$id-$skinIndex';
}
/// One dataset category, in emoji-mart's own order.
@immutable
class EmojiCategory {
final String id;
final List<EmojiEntry> emoji;
const EmojiCategory({required this.id, required this.emoji});
/// Title-cased label for the category rail.
String get label => switch (id) {
'people' => 'Smileys & People',
'nature' => 'Animals & Nature',
'foods' => 'Food & Drink',
'activity' => 'Activity',
'places' => 'Travel & Places',
'objects' => 'Objects',
'symbols' => 'Symbols',
'flags' => 'Flags',
_ => id,
};
}
/// Parsed emoji dataset: ordered categories, a flat list for search, and a
/// reverse glyph index for resolving a reaction back to its shortcode.
@immutable
class EmojiDataset {
final List<EmojiCategory> categories;
/// Every entry, in category order. Search scans this.
final List<EmojiEntry> all;
/// Glyph to `:shortcode:`. Mirrors desktop's `emojiDisplayName`.
final Map<String, String> nativeToShortcode;
const EmojiDataset({
required this.categories,
required this.all,
required this.nativeToShortcode,
});
static const empty = EmojiDataset(
categories: [],
all: [],
nativeToShortcode: {},
);
bool get isEmpty => all.isEmpty;
/// Resolve a reaction value to something displayable as a name: a custom
/// emoji's `:shortcode:` passes through, a Unicode glyph maps to its
/// shortcode, and anything unknown falls back to itself.
String displayName(String emoji) {
final trimmed = emoji.trim();
if (trimmed.startsWith(':') && trimmed.endsWith(':')) return trimmed;
return nativeToShortcode[trimmed] ?? trimmed;
}
/// Parse the generated asset. Runs on a background isolate via `compute`, so
/// it must stay a top-level-callable pure function over plain JSON.
static EmojiDataset fromJson(Map<String, dynamic> json) {
final rawEmoji = (json['emoji'] as Map).cast<String, dynamic>();
final rawCategories = (json['categories'] as List).cast<dynamic>();
final categories = <EmojiCategory>[];
final all = <EmojiEntry>[];
final nativeToShortcode = <String, String>{};
for (final rawCategory in rawCategories) {
final category = (rawCategory as Map).cast<String, dynamic>();
final categoryId = category['id'] as String;
final entries = <EmojiEntry>[];
for (final rawId in (category['emoji'] as List).cast<dynamic>()) {
final id = rawId as String;
final record = (rawEmoji[id] as Map?)?.cast<String, dynamic>();
if (record == null) continue;
// Older committed assets used one string; accept that shape so the
// parser remains safe while generated assets move to all skin variants.
final natives = switch (record['u']) {
final List values => values.cast<String>(),
final String value => [value],
_ => const <String>[],
};
for (final (skinIndex, native) in natives.indexed) {
final entry = EmojiEntry(
id: id,
name: record['n'] as String,
keywords: (record['k'] as List).cast<String>(),
native: native,
categoryId: categoryId,
skinIndex: skinIndex,
);
entries.add(entry);
all.add(entry);
// First writer wins so the earliest category owns a shared glyph,
// matching desktop's map build order.
nativeToShortcode.putIfAbsent(entry.native, () => ':$id:');
}
}
categories.add(EmojiCategory(id: categoryId, emoji: entries));
}
return EmojiDataset(
categories: categories,
all: all,
nativeToShortcode: nativeToShortcode,
);
}
}
@@ -0,0 +1,38 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'emoji_data.dart';
/// Asset path for the generated dataset. See `mobile/scripts/generate-emoji-data.mjs`.
const emojiDataAssetPath = 'assets/emoji/emoji-data.json';
/// Decode + parse off the UI isolate. ~1.9k entries is fast but not free, and
/// the picker sheet animates in while this runs.
EmojiDataset _parseEmojiData(String source) {
return EmojiDataset.fromJson(jsonDecode(source) as Map<String, dynamic>);
}
/// The standard emoji dataset, loaded once from the app bundle.
final emojiDatasetProvider = FutureProvider<EmojiDataset>((ref) async {
final source = await rootBundle.loadString(emojiDataAssetPath);
return compute(_parseEmojiData, source);
});
/// Synchronous read of the dataset — [EmojiDataset.empty] while loading or on
/// error. Convenience for widgets that only need the resolved value, mirroring
/// `customEmojiListProvider`.
final emojiDatasetOrEmptyProvider = Provider<EmojiDataset>((ref) {
return ref.watch(emojiDatasetProvider).value ?? EmojiDataset.empty;
});
/// Every glyph in the dataset, as a set for membership tests.
///
/// Built once per dataset load rather than per message: `isEmojiOnlyMessage`
/// runs on every rendered body, and materializing ~1.9k keys there would be a
/// per-frame cost.
final nativeEmojiGlyphsProvider = Provider<Set<String>>((ref) {
return ref.watch(emojiDatasetOrEmptyProvider).nativeToShortcode.keys.toSet();
});
+87
View File
@@ -0,0 +1,87 @@
/// Detection and sizing for messages whose entire body is emoji, ported from
/// desktop's `isEmojiOnlyMessage` (`desktop/src/shared/lib/emojiOnly.ts`) and
/// the `emojiOnly` branch of `MessageRow.tsx`.
library;
import 'package:characters/characters.dart';
import '../custom_emoji/custom_emoji.dart';
/// Body size for an emoji-only message. Desktop steps `text-sm` (14px) up to
/// `text-4xl`; mobile body text is the same 14px, so the same 36px lands the
/// same jump.
const double kEmojiOnlyFontSize = 36.0;
/// Line height for an emoji-only body — desktop's `leading-tight`. Emoji have
/// generous internal leading already, so normal line spacing leaves a gap that
/// reads as a blank row.
const double kEmojiOnlyHeight = 1.25;
/// Inline custom-emoji size inside an emoji-only message. Desktop sizes these
/// at `1.45em` of the surrounding text.
const double kEmojiOnlyCustomEmojiSize = kEmojiOnlyFontSize * 1.45;
/// Anything with `Extended_Pictographic` in it is emoji enough. Keycaps and
/// digit-based sequences don't match — those are covered by the dataset's own
/// native set instead, exactly as desktop does it.
final RegExp _pictographic = RegExp(
r'\p{Extended_Pictographic}',
unicode: true,
);
/// Whether [content] is nothing but emoji — native glyphs, known custom
/// `:shortcode:`s, and whitespace, with at least one emoji present.
///
/// [nativeEmoji] is the dataset's glyph set — read `nativeEmojiGlyphsProvider`.
/// Passing an empty set still gives correct results for ordinary emoji, since
/// the pictographic check stands on its own; only glyphs with no pictographic
/// code point of their own (keycaps like 1️⃣) need the set.
bool isEmojiOnlyMessage(
String content, {
required Set<String> nativeEmoji,
List<CustomEmoji> customEmoji = const [],
}) {
final trimmed = content.trim();
if (trimmed.isEmpty) return false;
final shortcodes = {
for (final emoji in customEmoji) emoji.shortcode.toLowerCase(),
};
var sawEmoji = false;
// Grapheme clusters, so a ZWJ family or a flag pair is one unit. Desktop
// hand-rolls this scan; Dart's `characters` already segments correctly.
final iterator = trimmed.characters.iterator;
final clusters = <String>[];
while (iterator.moveNext()) {
clusters.add(iterator.current);
}
var index = 0;
while (index < clusters.length) {
final cluster = clusters[index];
if (cluster.trim().isEmpty) {
index += 1;
continue;
}
if (cluster == ':') {
final end = clusters.indexOf(':', index + 1);
if (end <= index + 1) return false;
final shortcode = clusters.sublist(index + 1, end).join().toLowerCase();
if (!shortcodes.contains(shortcode)) return false;
sawEmoji = true;
index = end + 1;
continue;
}
if (!nativeEmoji.contains(cluster) && !_pictographic.hasMatch(cluster)) {
return false;
}
sawEmoji = true;
index += 1;
}
return sawEmoji;
}
+214
View File
@@ -0,0 +1,214 @@
/// Emoji search for the mobile picker and reaction tray.
///
/// Ported from `desktop/src/shared/lib/emojiSearch.ts`, which exists because
/// emoji-mart's own search is token-prefix based and so can't cross the `_` in
/// a shortcode — `pointup` never finds `point_up`. The tiers keep ordinary
/// queries ranked the way you'd expect while still surfacing looser matches
/// last: exact > prefix > substring > subsequence over the shortcode.
///
/// Mobile extends the desktop tiers with name/keyword matching, because the
/// tray replaces emoji-mart's picker (which searched names and keywords) rather
/// than just its `:shortcode` autocomplete. Shortcode hits still outrank
/// keyword hits, so typing a shortcode you know never buries it.
library;
import 'emoji_data.dart';
// Lower tier = stronger match.
const _tierExact = 0;
const _tierShortcodePrefix = 1;
const _tierKeywordPrefix = 2;
const _tierShortcodeSubstring = 3;
const _tierKeywordSubstring = 4;
const _tierSubsequence = 5;
/// Sentinel ranking worse than every real tier.
const _noMatch = 1 << 20;
final _separators = RegExp(r'[:_\s-]');
final _wordSplit = RegExp(r'[\s_-]+');
/// Lowercase and drop separators (`:`, `_`, `-`, whitespace) so matching is
/// insensitive to shortcode punctuation. `point_up` and `pointup` collapse to
/// the same normalized form.
///
/// Named for what it does rather than desktop's `normalizeShortcode`, because
/// `custom_emoji.dart` already exports a validating `normalizeShortcode` with
/// different semantics (it returns null for malformed input).
String collapseSeparators(String value) {
return value.toLowerCase().replaceAll(_separators, '');
}
/// A ranked match. [score] is a within-tier tiebreak — lower is better.
class EmojiMatch {
final int tier;
final int score;
const EmojiMatch({required this.tier, required this.score});
}
/// If [query] is a subsequence of [target] (all chars in order, gaps allowed),
/// return the span of the match as a tightness score — smaller is tighter.
/// Returns null when it isn't a subsequence.
int? _subsequenceSpan(String query, String target) {
var first = -1;
var last = -1;
var qi = 0;
for (var ti = 0; ti < target.length && qi < query.length; ti++) {
if (target[ti] == query[qi]) {
if (first == -1) first = ti;
last = ti;
qi++;
}
}
if (qi < query.length) return null;
return last - first;
}
/// Score [query] against a single [shortcode]. Returns null when there is no
/// match at any tier. Both sides are normalized first, so separators are
/// ignored. This is the desktop-identical half of the ranking.
EmojiMatch? scoreShortcodeMatch(String query, String shortcode) {
final q = collapseSeparators(query);
if (q.isEmpty) return null;
final target = collapseSeparators(shortcode);
if (target.isEmpty) return null;
if (q == target) return const EmojiMatch(tier: _tierExact, score: 0);
if (target.startsWith(q)) {
return const EmojiMatch(tier: _tierShortcodePrefix, score: 0);
}
final index = target.indexOf(q);
if (index != -1) {
return EmojiMatch(tier: _tierShortcodeSubstring, score: index);
}
final span = _subsequenceSpan(q, target);
if (span != null) return EmojiMatch(tier: _tierSubsequence, score: span);
return null;
}
/// Score [query] against an entry's name and keywords, word by word. Used to
/// top up shortcode matching so a query like `smiling` finds emoji whose
/// shortcode doesn't contain it.
EmojiMatch? _scoreKeywordMatch(
String query,
String name,
List<String> keywords,
) {
final q = query.toLowerCase().trim();
if (q.isEmpty) return null;
var bestTier = _noMatch;
var bestScore = 0;
// Earlier words rank better: the name comes before the keywords, and within
// each the dataset's own order is meaningful.
void consider(String candidate, int wordIndex) {
if (bestTier == _tierKeywordPrefix) return;
final word = candidate.toLowerCase();
if (word.isEmpty) return;
final tier = word.startsWith(q)
? _tierKeywordPrefix
: word.contains(q)
? _tierKeywordSubstring
: _noMatch;
if (tier < bestTier) {
bestTier = tier;
bestScore = wordIndex;
}
}
var wordIndex = 0;
for (final word in name.split(_wordSplit)) {
consider(word, wordIndex++);
}
for (final keyword in keywords) {
for (final word in keyword.split(_wordSplit)) {
consider(word, wordIndex++);
}
}
if (bestTier == _noMatch) return null;
return EmojiMatch(tier: bestTier, score: bestScore);
}
class _Scored<T> {
final T item;
final int tier;
final int score;
final String code;
const _Scored({
required this.item,
required this.tier,
required this.score,
required this.code,
});
}
int _compare<T>(_Scored<T> a, _Scored<T> b) {
if (a.tier != b.tier) return a.tier - b.tier;
if (a.score != b.score) return a.score - b.score;
// Shorter shortcode is the more specific match, then alphabetical for a
// stable result.
if (a.code.length != b.code.length) return a.code.length - b.code.length;
return a.code.compareTo(b.code);
}
/// Rank [items] by how well their shortcode matches [query], best first, capped
/// at [limit]. Mirrors desktop's `rankByShortcode` — used for the custom-emoji
/// palette, which has no names or keywords.
List<T> rankByShortcode<T>(
String query,
List<T> items,
String Function(T item) shortcodeOf, {
int? limit,
}) {
if (limit != null && limit <= 0) return const [];
final scored = <_Scored<T>>[];
for (final item in items) {
final code = shortcodeOf(item);
final match = scoreShortcodeMatch(query, code);
if (match == null) continue;
scored.add(
_Scored(item: item, tier: match.tier, score: match.score, code: code),
);
}
scored.sort(_compare);
final ranked = scored.map((entry) => entry.item).toList();
if (limit == null || ranked.length <= limit) return ranked;
return ranked.sublist(0, limit);
}
/// Rank standard emoji by shortcode, name, and keywords, best first.
List<EmojiEntry> searchEmoji(
String query,
List<EmojiEntry> entries, {
int? limit,
}) {
if (limit != null && limit <= 0) return const [];
final scored = <_Scored<EmojiEntry>>[];
for (final entry in entries) {
final shortcode = scoreShortcodeMatch(query, entry.id);
final keyword = _scoreKeywordMatch(query, entry.name, entry.keywords);
final match = switch ((shortcode, keyword)) {
(null, null) => null,
(final EmojiMatch only, null) => only,
(null, final EmojiMatch only) => only,
(final EmojiMatch a, final EmojiMatch b) => a.tier <= b.tier ? a : b,
};
if (match == null) continue;
scored.add(
_Scored(
item: entry,
tier: match.tier,
score: match.score,
code: entry.id,
),
);
}
scored.sort(_compare);
final ranked = scored.map((entry) => entry.item).toList();
if (limit == null || ranked.length <= limit) return ranked;
return ranked.sublist(0, limit);
}
@@ -0,0 +1,22 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
/// A standalone system emoji whose visual centre matches its surrounding UI.
///
/// Apple's emoji glyphs sit slightly low inside Flutter's text box. Keep the
/// layout box unchanged and lift only the painted glyph on iOS; Android's
/// system emoji metrics are already visually centred.
class NativeEmojiGlyph extends StatelessWidget {
final String emoji;
final double size;
const NativeEmojiGlyph({super.key, required this.emoji, required this.size});
@override
Widget build(BuildContext context) {
final glyph = Text(emoji, style: TextStyle(fontSize: size));
if (defaultTargetPlatform != TargetPlatform.iOS) return glyph;
return Transform.translate(offset: const Offset(0, -1), child: glyph);
}
}
@@ -0,0 +1,91 @@
/// The emoji that earn a particle burst.
///
/// Ported verbatim from `POSITIVE_EMOJI_PARTICLES` in
/// `desktop/src/shared/ui/EmojiBurstProvider.tsx`. Only affirmation reacts
/// celebrate — a 😢 or 👎 shouldn't throw confetti — so the three groups below
/// are the whole gate, and both clients must agree on them or the same reaction
/// bursts on one device and not the other.
library;
const heartParticleEmojis = <String>[
'\u{2764}\u{FE0F}', // ❤️
'\u{1FA77}', // 🩷
'\u{1F9E1}', // 🧡
'\u{1F49B}', // 💛
'\u{1F49A}', // 💚
'\u{1FA75}', // 🩵
'\u{1F499}', // 💙
'\u{1F49C}', // 💜
'\u{1F90E}', // 🤎
'\u{1F5A4}', // 🖤
'\u{1FA76}', // 🩶
'\u{1F90D}', // 🤍
'\u{2764}\u{FE0F}\u{200D}\u{1F525}', // ❤️‍🔥
'\u{2764}\u{FE0F}\u{200D}\u{1FA79}', // ❤️‍🩹
'\u{1F496}', // 💖
'\u{1F495}', // 💕
'\u{1F497}', // 💗
'\u{1F493}', // 💓
'\u{1F49E}', // 💞
'\u{1F498}', // 💘
'\u{1F49D}', // 💝
'\u{2763}\u{FE0F}', // ❣️
'\u{2665}\u{FE0F}', // ♥️
];
const positiveReactionParticleEmojis = <String>[
'\u{1F44D}', // 👍
'\u{1F44F}', // 👏
'\u{1F64C}', // 🙌
'\u{1F64F}', // 🙏
'\u{1F4AF}', // 💯
'\u{1F525}', // 🔥
'\u{2728}', // ✨
'\u{2B50}', // ⭐
'\u{1F31F}', // 🌟
'\u{1F389}', // 🎉
'\u{1F38A}', // 🎊
'\u{1F973}', // 🥳
'\u{1F680}', // 🚀
'\u{1F4AA}', // 💪
];
const positiveFaceParticleEmojis = <String>[
'\u{1F600}', // 😀
'\u{1F603}', // 😃
'\u{1F604}', // 😄
'\u{1F601}', // 😁
'\u{1F60A}', // 😊
'\u{1F607}', // 😇
'\u{1F642}', // 🙂
'\u{1F60D}', // 😍
'\u{1F929}', // 🤩
'\u{1F970}', // 🥰
'\u{1F618}', // 😘
'\u{1F60B}', // 😋
'\u{1F60E}', // 😎
'\u{1F602}', // 😂
'\u{1F923}', // 🤣
];
const positiveEmojiParticles = <String>[
...heartParticleEmojis,
...positiveReactionParticleEmojis,
...positiveFaceParticleEmojis,
];
/// Emoji presentation selectors carry no meaning for this comparison, and the
/// same emoji reaches us with and without one depending on where it came from
/// (the emoji-mart dataset, a hand-typed reaction, an older client). Desktop
/// gets away with exact set membership because its picker is the only producer;
/// mobile reactions also arrive off the wire, so fold the selector away.
String _foldSelectors(String emoji) =>
emoji.trim().replaceAll('\u{FE0F}', '').replaceAll('\u{FE0E}', '');
final Set<String> _positiveEmojiParticleSet = {
for (final emoji in positiveEmojiParticles) _foldSelectors(emoji),
};
/// Whether [emoji] should burst. Mirrors desktop's `isPositiveEmojiParticle`.
bool isPositiveEmojiParticle(String emoji) =>
_positiveEmojiParticleSet.contains(_foldSelectors(emoji));
+10
View File
@@ -0,0 +1,10 @@
import 'package:flutter/widgets.dart';
import '../../l10n/app_localizations.dart';
export '../../l10n/app_localizations.dart';
export 'locale_provider.dart';
extension BuzzLocalizations on BuildContext {
AppLocalizations get l10n => AppLocalizations.of(this)!;
}
@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../theme/theme_provider.dart';
const _localeKey = 'buzz_locale';
/// A null locale follows the operating system. Explicit choices are persisted
/// locally because language is a device preference, not relay protocol state.
class AppLocaleNotifier extends Notifier<Locale?> {
@override
Locale? build() {
return switch (ref.read(savedPrefsProvider).getString(_localeKey)) {
'en' => const Locale('en'),
'zh_CN' => const Locale('zh', 'CN'),
_ => null,
};
}
void setLocale(Locale? locale) {
state = locale;
final prefs = ref.read(savedPrefsProvider);
if (locale == null) {
prefs.setString(_localeKey, 'system');
} else if (locale.languageCode == 'zh') {
prefs.setString(_localeKey, 'zh_CN');
} else {
prefs.setString(_localeKey, 'en');
}
}
}
final appLocaleProvider = NotifierProvider<AppLocaleNotifier, Locale?>(
AppLocaleNotifier.new,
);
@@ -0,0 +1,274 @@
import 'dart:collection';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/crypto/nip_oa.dart';
import '../../shared/relay/relay.dart';
/// A relay agent parsed from its kind:10100 agent-profile event.
///
/// Mirrors the fields desktop's `RelayAgent` uses for mention eligibility
/// (`agentAutocompleteEligibility.ts`): who the agent responds to and which
/// channels it sits in.
class AgentDirectoryEntry {
final String pubkey;
final String? displayName;
final String? respondTo;
final List<String> respondToAllowlist;
final List<String> channelIds;
const AgentDirectoryEntry({
required this.pubkey,
this.displayName,
this.respondTo,
this.respondToAllowlist = const [],
this.channelIds = const [],
});
factory AgentDirectoryEntry.fromEvent(NostrEvent event) {
final content = _tryDecodeJsonMap(event.content);
return AgentDirectoryEntry(
pubkey: event.pubkey.toLowerCase(),
displayName:
(content?['display_name'] as String?) ??
(content?['name'] as String?),
respondTo: content?['respond_to'] as String?,
respondToAllowlist: [
for (final value in (content?['respond_to_allowlist'] as List?) ?? [])
if (value is String) value.toLowerCase(),
],
channelIds: [
for (final value in (content?['channel_ids'] as List?) ?? [])
if (value is String) value,
],
);
}
}
Map<String, dynamic>? _tryDecodeJsonMap(String content) {
try {
final decoded = jsonDecode(content);
return decoded is Map<String, dynamic> ? decoded : null;
} catch (_) {
return null;
}
}
/// Relay agent directory from kind:10100 agent-profile events.
///
/// Watches the session and only fetches after the WebSocket connects.
final agentDirectoryProvider = FutureProvider<List<AgentDirectoryEntry>>((
ref,
) async {
final sessionState = ref.watch(relaySessionProvider);
if (sessionState.status != SessionStatus.connected) return const [];
final session = ref.read(relaySessionProvider.notifier);
final events = await session.fetchHistory(NostrFilters.agentProfiles());
return [for (final event in events) AgentDirectoryEntry.fromEvent(event)];
});
/// Verified NIP-OA owner pubkey per agent pubkey, from the agents' kind:0
/// profiles. An entry exists only when the `auth` tag verifies — mirrors
/// desktop's `profile_valid_oa_owner_pubkey`.
final agentOwnersProvider = FutureProvider<Map<String, String>>((ref) async {
final agents = await ref.watch(agentDirectoryProvider.future);
if (agents.isEmpty) return const {};
final session = ref.read(relaySessionProvider.notifier);
final events = await session.fetchHistory(
NostrFilters.profilesBatch([for (final agent in agents) agent.pubkey]),
);
final owners = <String, String>{};
for (final event in events) {
final owner = verifiedOaOwnerPubkey(event.tags, event.pubkey);
if (owner != null) owners[event.pubkey.toLowerCase()] = owner;
}
return owners;
});
/// Pubkeys currently known to represent agents across the active relay.
///
/// Message surfaces that do not own channel membership can use this shared
/// identity source; channel features add their bot roles separately.
final knownAgentPubkeysProvider = Provider<Set<String>>((ref) {
final relayAgents =
ref.watch(agentDirectoryProvider).asData?.value ??
const <AgentDirectoryEntry>[];
final owners = ref.watch(agentOwnersProvider).asData?.value ?? const {};
return _AgentPubkeySet({
for (final agent in relayAgents) agent.pubkey.toLowerCase(),
...owners.keys.map((pubkey) => pubkey.toLowerCase()),
});
});
/// Directory display names keyed by agent pubkey for mention presentation.
final agentDirectoryDisplayNamesProvider = Provider<Map<String, String>>((ref) {
final agents =
ref.watch(agentDirectoryProvider).asData?.value ??
const <AgentDirectoryEntry>[];
return Map.unmodifiable({
for (final agent in agents)
if (agent.displayName?.trim().isNotEmpty == true)
agent.pubkey.toLowerCase(): agent.displayName!.trim(),
});
});
/// Adds channel bot roles to relay-wide agent identities.
Set<String> agentPubkeysWithChannelBots({
required Set<String> knownAgentPubkeys,
required Iterable<String> channelBotPubkeys,
}) => _AgentPubkeySet({
...knownAgentPubkeys,
...channelBotPubkeys.map((pubkey) => pubkey.toLowerCase()),
});
/// Adds agent identities derived from locally cached verified profiles.
Set<String> agentPubkeysWithProfileOwners({
required Set<String> knownAgentPubkeys,
required Iterable<String> profileOwnedAgentPubkeys,
}) => _AgentPubkeySet({
...knownAgentPubkeys,
...profileOwnedAgentPubkeys.map((pubkey) => pubkey.toLowerCase()),
});
/// Preserves profile labels while filling missing agent mentions from the
/// relay's agent directory.
Map<String, String> mentionNamesWithDirectoryLabels({
required Iterable<String> mentionPubkeys,
required Map<String, String> profileMentionNames,
required Map<String, String> directoryDisplayNames,
required Set<String> agentMentionPubkeys,
}) {
final names = Map<String, String>.from(profileMentionNames);
for (final pubkey in mentionPubkeys) {
final normalizedPubkey = pubkey.toLowerCase();
if (names[normalizedPubkey]?.trim().isEmpty == true) {
names.remove(normalizedPubkey);
}
final directoryName = directoryDisplayNames[normalizedPubkey];
if (!names.containsKey(normalizedPubkey) && directoryName != null) {
names[normalizedPubkey] = directoryName;
}
if (!names.containsKey(normalizedPubkey) &&
agentMentionPubkeys.contains(normalizedPubkey)) {
names[normalizedPubkey] = _agentFallbackLabel(normalizedPubkey);
}
}
return names;
}
String _agentFallbackLabel(String pubkey) =>
pubkey.length >= 8 ? pubkey.substring(0, 8) : pubkey;
/// Keeps the role feed alive for consumers that render mentions outside the
/// channel timeline, such as search results. A membership change refreshes the
/// shared bot-role lookup below, regardless of which surface owns the channel.
class _ChannelBotRoleSubscription extends Notifier<int> {
final String channelId;
void Function()? _unsubscribe;
int _subscriptionVersion = 0;
_ChannelBotRoleSubscription(this.channelId);
@override
int build() {
final sessionState = ref.watch(relaySessionProvider);
final subscriptionVersion = ++_subscriptionVersion;
_clearSubscription();
ref.onDispose(() {
_subscriptionVersion++;
_clearSubscription();
});
if (sessionState.status != SessionStatus.connected) return 0;
Future.microtask(() => _subscribe(channelId, subscriptionVersion));
return 0;
}
Future<void> _subscribe(String channelId, int subscriptionVersion) async {
final session = ref.read(relaySessionProvider.notifier);
try {
final unsubscribe = await session.subscribe(
NostrFilter(
kinds: const [39002],
tags: {
'#h': [channelId],
},
).copyWithSince(DateTime.now().millisecondsSinceEpoch ~/ 1000),
(_) {
if (_isCurrent(subscriptionVersion)) {
state++;
}
},
);
if (!_isCurrent(subscriptionVersion)) {
unsubscribe();
return;
}
_unsubscribe = unsubscribe;
} catch (error) {
if (_isCurrent(subscriptionVersion)) {
debugPrint(
'[ChannelBotRoleSubscription] failed for $channelId: $error',
);
}
}
}
bool _isCurrent(int subscriptionVersion) =>
subscriptionVersion == _subscriptionVersion;
void _clearSubscription() {
_unsubscribe?.call();
_unsubscribe = null;
}
}
/// Monotonically increments when the channel's kind:39002 membership snapshot
/// changes. Channel-member and agent-role views share this source so remote
/// membership updates refresh both snapshots together.
final channelMembershipUpdateProvider = NotifierProvider.autoDispose
.family<_ChannelBotRoleSubscription, int, String>(
_ChannelBotRoleSubscription.new,
);
/// Bot pubkeys currently assigned a channel bot role.
final channelBotPubkeysProvider = FutureProvider.autoDispose
.family<Set<String>, String>((ref, channelId) async {
ref.watch(channelMembershipUpdateProvider(channelId));
final sessionState = ref.watch(relaySessionProvider);
if (sessionState.status != SessionStatus.connected) return const {};
final session = ref.read(relaySessionProvider.notifier);
final events = await session.fetchHistory(
NostrFilters.channelMembers(channelId),
);
if (events.isEmpty) return const {};
return _AgentPubkeySet({
for (final member in membersFromEvent(events.first))
if (member.role == 'bot') member.pubkey.toLowerCase(),
});
});
/// Pubkeys currently known to represent agents in a channel.
final agentMentionPubkeysProvider = Provider.autoDispose
.family<Set<String>, String>((ref, channelId) {
final channelBotPubkeys =
ref.watch(channelBotPubkeysProvider(channelId)).asData?.value ??
const <String>{};
return agentPubkeysWithChannelBots(
knownAgentPubkeys: ref.watch(knownAgentPubkeysProvider),
channelBotPubkeys: channelBotPubkeys,
);
});
class _AgentPubkeySet extends UnmodifiableSetView<String> {
_AgentPubkeySet(Iterable<String> pubkeys) : super(Set.unmodifiable(pubkeys));
@override
bool operator ==(Object other) =>
other is Set && length == other.length && every(other.contains);
@override
int get hashCode => Object.hashAllUnordered(this);
}
@@ -0,0 +1,6 @@
/// Pubkeys tagged as message mentions, normalized for profile lookups.
Set<String> mentionedPubkeysFromTags(Iterable<List<String>> tags) => {
for (final tag in tags)
if (tag.length >= 2 && (tag[0] == 'p' || tag[0] == 'mention'))
tag[1].toLowerCase(),
};
@@ -0,0 +1,14 @@
import 'package:flutter/widgets.dart';
VoidCallback deferReadStateUpdate(BuildContext context, VoidCallback update) {
var cancelled = false;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!cancelled && context.mounted) {
update();
}
});
return () {
cancelled = true;
};
}
@@ -0,0 +1,44 @@
import 'read_state_format.dart';
import 'read_state_provider.dart';
/// Effective read timestamp for a single message: the newest of the channel
/// marker, the message's own `msg:` marker, and (for thread replies) the
/// thread's `thread:` marker — the same precedence the unread badge uses via
/// `observedUnreadEventReadAt`.
int? effectiveMessageReadAt(
ReadStateState readState, {
required String channelId,
required String messageId,
String? threadRootId,
}) {
return maxReadAt([
readState.effectiveTimestamp(channelId),
readState.effectiveTimestamp(msgContextKey(messageId)),
if (threadRootId != null)
readState.effectiveTimestamp(threadContextKey(threadRootId)),
]);
}
/// Whether a message should currently show as unread in the actions menu.
///
/// A message-level forced unread (from this menu) wins; otherwise the
/// timestamp precedence decides. A channel-level forced unread (from the
/// channel tile) is deliberately not consulted: it is a channel-scoped
/// choice, and letting it leak in here would make the message-level toggle
/// unable to round-trip without clearing the channel-level choice.
bool isMessageUnread(
ReadStateState readState, {
required String channelId,
required String messageId,
required int createdAt,
String? threadRootId,
}) {
if (readState.isForcedUnread(msgContextKey(messageId))) return true;
final readAt = effectiveMessageReadAt(
readState,
channelId: channelId,
messageId: messageId,
threadRootId: threadRootId,
);
return readAt == null || createdAt > readAt;
}
@@ -0,0 +1,193 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import '../relay/nostr_models.dart';
const readStateDTagPrefix = 'read-state:';
const readStateFetchLimit = 500;
const readStateHorizonSeconds = 7 * 24 * 60 * 60;
const _maxContexts = 10000;
const msgContextPrefix = 'msg:';
const threadContextPrefix = 'thread:';
String msgContextKey(String messageId) => '$msgContextPrefix$messageId';
String threadContextKey(String rootId) => '$threadContextPrefix$rootId';
int? maxReadAt(Iterable<int?> markers) {
int? latest;
for (final marker in markers) {
if (marker == null) continue;
if (latest == null || marker > latest) {
latest = marker;
}
}
return latest;
}
typedef ReadStateDecrypt = String Function(String ciphertext);
class ReadStateBlob {
final String clientId;
final Map<String, int> contexts;
ReadStateBlob({required this.clientId, required Map<String, int> contexts})
: contexts = Map.unmodifiable(contexts);
Map<String, dynamic> toJson() => {
'v': 1,
'client_id': clientId,
'contexts': contexts,
};
}
class DecodedReadStateEvent {
final NostrEvent event;
final String dTag;
final ReadStateBlob blob;
const DecodedReadStateEvent({
required this.event,
required this.dTag,
required this.blob,
});
}
bool isPlainJsonObject(Object? value) {
if (value is! Map) return false;
return value.keys.every((key) => key is String);
}
Map<String, Object?>? asStringObjectMap(Object? value) {
if (!isPlainJsonObject(value)) return null;
return (value as Map).cast<String, Object?>();
}
bool isValidReadStateDTag(String? value) {
if (value == null || !value.startsWith(readStateDTagPrefix)) {
return false;
}
final slotId = value.substring(readStateDTagPrefix.length);
if (slotId.isEmpty || slotId.length > 64) {
return false;
}
for (var index = 0; index < slotId.length; index++) {
if (slotId.codeUnitAt(index) > 0x7f) {
return false;
}
}
return true;
}
bool hasValidReadStateTags(NostrEvent event) {
final dTags = event.tags.where((tag) => tag.isNotEmpty && tag[0] == 'd');
if (dTags.length != 1) {
return false;
}
final dTag = dTags.single;
if (dTag.length < 2 || !isValidReadStateDTag(dTag[1])) {
return false;
}
final tTags = event.tags.where(
(tag) => tag.length >= 2 && tag[0] == 't' && tag[1] == 'read-state',
);
return tTags.length == 1;
}
ReadStateBlob? decodeReadStateBlob(String plaintext) {
final Object? parsed;
try {
parsed = jsonDecode(plaintext);
} catch (_) {
return null;
}
final record = asStringObjectMap(parsed);
if (record == null) return null;
if (record['v'] != 1) return null;
final clientId = record['client_id'];
if (clientId is! String || clientId.isEmpty || clientId.runes.length > 64) {
return null;
}
final contexts = asStringObjectMap(record['contexts']);
if (contexts == null || contexts.length > _maxContexts) {
return null;
}
return ReadStateBlob(
clientId: clientId,
contexts: sanitizeReadStateContexts(contexts),
);
}
Map<String, int> sanitizeReadStateContexts(Map<String, Object?> contexts) {
final sanitized = <String, int>{};
for (final entry in contexts.entries) {
if (utf8.encode(entry.key).length > 256) continue;
final value = entry.value;
if (value is! int) continue;
if (value < 0 || value > 4294967295) continue;
sanitized[entry.key] = value;
}
return sanitized;
}
DecodedReadStateEvent? decodeReadStateEvent(
NostrEvent event, {
required String pubkey,
required ReadStateDecrypt decrypt,
}) {
if (event.pubkey.toLowerCase() != pubkey.toLowerCase()) {
return null;
}
if (!hasValidReadStateTags(event)) {
return null;
}
final dTag = event.tags.firstWhere(
(tag) => tag.isNotEmpty && tag[0] == 'd',
)[1];
final String plaintext;
try {
plaintext = decrypt(event.content);
} catch (e) {
debugPrint(
'[ReadStateManager] decrypt failed for event ${event.id.substring(0, 8)}…: $e',
);
return null;
}
final blob = decodeReadStateBlob(plaintext);
if (blob == null) {
debugPrint(
'[ReadStateManager] blob decode failed for event ${event.id.substring(0, 8)}',
);
return null;
}
return DecodedReadStateEvent(event: event, dTag: dTag, blob: blob);
}
Map<String, int> mergeReadStateContexts(
Iterable<Map<String, int>> contextSets,
) {
final merged = <String, int>{};
for (final contexts in contextSets) {
for (final entry in contexts.entries) {
final current = merged[entry.key] ?? 0;
if (entry.value > current) {
merged[entry.key] = entry.value;
}
}
}
return merged;
}
@@ -0,0 +1,555 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
import '../crypto/nip44.dart';
import '../relay/relay.dart';
import 'read_state_format.dart';
import 'read_state_storage.dart';
import 'read_state_time.dart';
class ReadStateCrypto {
final Uint8List conversationKey;
const ReadStateCrypto._(this.conversationKey);
static ReadStateCrypto? tryCreate({
required String nsec,
required String pubkey,
}) {
try {
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
if (privkeyHex.isEmpty || pubkey.isEmpty) {
return null;
}
return ReadStateCrypto._(getConversationKey(privkeyHex, pubkey));
} catch (e) {
debugPrint('[ReadStateManager] crypto init failed: $e');
return null;
}
}
String encrypt(String plaintext) => nip44Encrypt(conversationKey, plaintext);
String decrypt(String ciphertext) =>
nip44Decrypt(conversationKey, ciphertext);
}
enum _ApplyRemoteContextResult { unchanged, advanced }
class ReadStateManager {
final String pubkey;
final ReadStateCrypto _crypto;
final ReadStateStorage _storage;
final RelaySessionNotifier? _relaySession;
final SignedEventRelay? _signedEventRelay;
final bool _remoteEnabled;
final VoidCallback _onChanged;
late final String _clientId;
late String _slotId;
final Map<String, int> _effectiveState = {};
final Set<String> _publishableContextIds = {};
Map<String, int> _lastPublishedContexts = {};
Timer? _debounceTimer;
void Function()? _unsubscribeLive;
bool _initialized = false;
bool _disposed = false;
bool _isPublishing = false;
Completer<void>? _publishCompleter;
bool _remoteUnsupported = false;
int _maxFetchedCreatedAt = 0;
final Map<String, int> _contextSourceCreatedAt = {};
final Set<String> _pendingSyncedAdvances = {};
ReadStateManager({
required this.pubkey,
required SharedPreferences prefs,
required ReadStateCrypto crypto,
required RelaySessionNotifier? relaySession,
required SignedEventRelay? signedEventRelay,
required bool remoteEnabled,
required VoidCallback onChanged,
}) : _crypto = crypto,
_storage = ReadStateStorage(prefs),
_relaySession = relaySession,
_signedEventRelay = signedEventRelay,
_remoteEnabled = remoteEnabled,
_onChanged = onChanged {
_clientId = _storage.getOrCreateClientId(pubkey);
_slotId = _storage.getOrCreateSlotId(pubkey);
_hydrateFromLocalStorage();
}
Map<String, int> get effectiveContexts => Map.unmodifiable(_effectiveState);
int? getEffectiveTimestamp(String contextId) => _effectiveState[contextId];
Future<void> initialize() async {
if (_initialized || _disposed) return;
_initialized = true;
debugPrint(
'[ReadStateManager] initialize pubkey=${pubkey.substring(0, 8)}… clientId=${_clientId.substring(0, 8)}… slotId=$_slotId',
);
if (!_remoteEnabled || _relaySession == null) {
_onChanged();
return;
}
await _fetchAndMerge();
await _startLiveSubscription();
if (!_isIdenticalToLastPublished(_currentContexts())) {
_schedulePublish();
}
_onChanged();
debugPrint(
'[ReadStateManager] initialize complete maxFetchedCreatedAt=$_maxFetchedCreatedAt contexts=${_effectiveState.length}',
);
}
void markContextRead(String contextId, int unixTimestamp) {
_advanceContext(contextId, unixTimestamp, publishable: true);
_contextSourceCreatedAt[contextId] = max(
currentUnixSeconds(),
_maxFetchedCreatedAt + 1,
);
}
void seedContextRead(String contextId, int unixTimestamp) {
_advanceContext(contextId, unixTimestamp, publishable: false);
}
Future<void> flush() async {
_debounceTimer?.cancel();
_debounceTimer = null;
if (!_remoteEnabled || _remoteUnsupported || _disposed) return;
await _publish();
}
Future<void> reinitializeRemote() async {
if (_disposed || !_remoteEnabled || !_initialized) return;
debugPrint('[ReadStateManager] reinitializeRemote');
if (_isPublishing) {
await _publishCompleter?.future;
}
_unsubscribeLive?.call();
_unsubscribeLive = null;
await _fetchAndMerge();
await _startLiveSubscription();
if (!_isIdenticalToLastPublished(_currentContexts())) {
_schedulePublish();
}
_onChanged();
}
void dispose({bool flushPending = true}) {
if (_disposed) return;
_disposed = true;
final hadPendingPublish = _debounceTimer != null;
_debounceTimer?.cancel();
_debounceTimer = null;
if (flushPending &&
hadPendingPublish &&
_remoteEnabled &&
!_remoteUnsupported) {
unawaited(_publish(allowDisposed: true));
}
_unsubscribeLive?.call();
_unsubscribeLive = null;
}
void _advanceContext(
String contextId,
int unixTimestamp, {
required bool publishable,
}) {
if (_disposed || unixTimestamp < 0) return;
final current = _effectiveState[contextId] ?? 0;
if (unixTimestamp <= current) {
if (!publishable || _publishableContextIds.contains(contextId)) {
return;
}
_publishableContextIds.add(contextId);
_persistLocalState();
_onChanged();
_schedulePublish();
return;
}
_effectiveState[contextId] = unixTimestamp;
if (publishable) {
_publishableContextIds.add(contextId);
}
_persistLocalState();
_onChanged();
if (publishable) {
_schedulePublish();
}
}
Future<void> _fetchAndMerge() async {
try {
final events = await _relaySession!.fetchHistory(
NostrFilter(
kinds: const [EventKind.readState],
authors: [pubkey],
tags: const {
'#t': ['read-state'],
},
since: currentUnixSeconds() - readStateHorizonSeconds,
limit: readStateFetchLimit,
),
);
_mergeEvents(events);
_persistLocalState();
_onChanged();
} catch (e) {
debugPrint('[ReadStateManager] fetchAndMerge failed: $e');
}
}
void _mergeEvents(List<NostrEvent> events) {
ReadStateBlob? ownBlob;
var ownBlobCreatedAt = 0;
for (final event in events) {
final decoded = decodeReadStateEvent(
event,
pubkey: pubkey,
decrypt: _crypto.decrypt,
);
if (decoded == null) continue;
if (_isPlausibleCreatedAt(event.createdAt)) {
_maxFetchedCreatedAt = max(_maxFetchedCreatedAt, event.createdAt);
}
if (decoded.dTag == '$readStateDTagPrefix$_slotId' &&
decoded.blob.clientId != _clientId) {
_rotateSlotId();
}
for (final entry in decoded.blob.contexts.entries) {
final result = _applyRemoteContextTimestamp(
contextId: entry.key,
timestamp: entry.value,
eventCreatedAt: event.createdAt,
);
if (result == _ApplyRemoteContextResult.advanced) {
_pendingSyncedAdvances.add(entry.key);
_publishableContextIds.add(entry.key);
}
}
if (decoded.blob.clientId == _clientId &&
event.createdAt > ownBlobCreatedAt) {
ownBlob = decoded.blob;
ownBlobCreatedAt = event.createdAt;
}
}
if (ownBlob != null) {
_lastPublishedContexts = Map<String, int>.from(ownBlob.contexts);
_publishableContextIds.addAll(ownBlob.contexts.keys);
}
}
Future<void> _startLiveSubscription() async {
try {
final unsub = await _relaySession!.subscribe(
NostrFilter(
kinds: const [EventKind.readState],
authors: [pubkey],
tags: const {
'#t': ['read-state'],
},
limit: readStateFetchLimit,
),
_handleIncomingEvent,
);
if (_disposed) {
unsub.call();
return;
}
_unsubscribeLive = unsub;
debugPrint('[ReadStateManager] live subscription established');
} catch (e) {
debugPrint('[ReadStateManager] live subscription FAILED: $e');
}
}
void _handleIncomingEvent(NostrEvent event) {
if (_disposed) return;
debugPrint(
'[ReadStateManager] incoming event=${event.id.substring(0, 8)}… created_at=${event.createdAt}',
);
final decoded = decodeReadStateEvent(
event,
pubkey: pubkey,
decrypt: _crypto.decrypt,
);
if (decoded == null) return;
if (_isPlausibleCreatedAt(event.createdAt)) {
_maxFetchedCreatedAt = max(_maxFetchedCreatedAt, event.createdAt);
}
if (decoded.dTag == '$readStateDTagPrefix$_slotId' &&
decoded.blob.clientId != _clientId) {
_rotateSlotId();
}
var changed = false;
for (final entry in decoded.blob.contexts.entries) {
final result = _applyRemoteContextTimestamp(
contextId: entry.key,
timestamp: entry.value,
eventCreatedAt: event.createdAt,
);
if (result == _ApplyRemoteContextResult.advanced) {
_pendingSyncedAdvances.add(entry.key);
changed = true;
}
if (_publishableContextIds.add(entry.key)) {
changed = true;
}
}
debugPrint(
'[ReadStateManager] incoming result changed=$changed clientId=${decoded.blob.clientId.substring(0, min(8, decoded.blob.clientId.length))}',
);
if (decoded.blob.clientId == _clientId) {
_lastPublishedContexts = Map<String, int>.from(decoded.blob.contexts);
}
if (changed) {
_persistLocalState();
_onChanged();
}
if (decoded.blob.clientId != _clientId &&
!_isIdenticalToLastPublished(_currentContexts())) {
_schedulePublish();
}
}
_ApplyRemoteContextResult _applyRemoteContextTimestamp({
required String contextId,
required int timestamp,
required int eventCreatedAt,
}) {
final sourceCreatedAt = _contextSourceCreatedAt[contextId] ?? 0;
final current = _effectiveState[contextId] ?? 0;
final next = max(current, timestamp);
final result = next == current
? _ApplyRemoteContextResult.unchanged
: _ApplyRemoteContextResult.advanced;
if (result == _ApplyRemoteContextResult.advanced) {
_effectiveState[contextId] = next;
}
if (eventCreatedAt > sourceCreatedAt) {
_contextSourceCreatedAt[contextId] = eventCreatedAt;
}
return result;
}
void _schedulePublish() {
if (!_remoteEnabled || _remoteUnsupported || _disposed) return;
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(seconds: 5), () {
_debounceTimer = null;
unawaited(_publish());
});
}
Future<void> _publish({bool allowDisposed = false}) async {
if ((!allowDisposed && _disposed) ||
!_remoteEnabled ||
_remoteUnsupported ||
_signedEventRelay == null) {
return;
}
if (_isPublishing) return;
final completer = Completer<void>();
_publishCompleter = completer;
_isPublishing = true;
debugPrint('[ReadStateManager] publish starting slotId=$_slotId');
try {
await _fetchOwnBlobBeforePublish();
final contexts = _currentContexts();
if (_isIdenticalToLastPublished(contexts)) {
return;
}
final blob = ReadStateBlob(clientId: _clientId, contexts: contexts);
final ciphertext = _crypto.encrypt(jsonEncode(blob.toJson()));
final createdAt = max(currentUnixSeconds(), _maxFetchedCreatedAt + 1);
await _signedEventRelay.submit(
kind: EventKind.readState,
content: ciphertext,
tags: [
['d', '$readStateDTagPrefix$_slotId'],
['t', 'read-state'],
],
createdAt: createdAt,
);
debugPrint('[ReadStateManager] publish accepted createdAt=$createdAt');
for (final key in contexts.keys) {
if (_lastPublishedContexts[key] != contexts[key]) {
_contextSourceCreatedAt[key] = createdAt;
}
}
_lastPublishedContexts = contexts;
_maxFetchedCreatedAt = max(_maxFetchedCreatedAt, createdAt);
_persistLocalState();
} catch (error) {
if (_isOversizedReadStateError(error)) {
_remoteUnsupported = true;
_debounceTimer?.cancel();
_debounceTimer = null;
debugPrint(
'[ReadStateManager] remote read-state sync disabled because the '
'local state exceeds the NIP-44 plaintext limit.',
);
return;
}
if (_isPermanentReadStateRemoteError(error)) {
_remoteUnsupported = true;
_debounceTimer?.cancel();
_debounceTimer = null;
debugPrint(
'[ReadStateManager] remote read-state sync is unavailable; '
'using local read state.',
);
return;
}
debugPrint('[ReadStateManager] publish failed: $error');
} finally {
_isPublishing = false;
completer.complete();
if (_publishCompleter == completer) {
_publishCompleter = null;
}
}
}
Future<void> _fetchOwnBlobBeforePublish() async {
if (_relaySession == null) return;
try {
final events = await _relaySession.fetchHistory(
NostrFilter(
kinds: const [EventKind.readState],
authors: [pubkey],
tags: {
'#d': ['$readStateDTagPrefix$_slotId'],
},
limit: readStateFetchLimit,
),
);
_mergeEvents(events);
_persistLocalState();
if (!_disposed) {
_onChanged();
}
} catch (e) {
debugPrint('[ReadStateManager] fetchOwnBlobBeforePublish failed: $e');
}
}
bool _isIdenticalToLastPublished(Map<String, int> contexts) {
if (_lastPublishedContexts.length != contexts.length) {
return false;
}
for (final entry in contexts.entries) {
if (_lastPublishedContexts[entry.key] != entry.value) {
return false;
}
}
return true;
}
Set<String> drainSyncedAdvances() {
final drained = Set<String>.from(_pendingSyncedAdvances);
_pendingSyncedAdvances.clear();
return drained;
}
Map<String, int> _currentContexts() {
final contexts = <String, int>{};
for (final entry in _effectiveState.entries) {
if (_publishableContextIds.contains(entry.key)) {
contexts[entry.key] = entry.value;
}
}
return contexts;
}
void _hydrateFromLocalStorage() {
final stored = _storage.read(pubkey);
_effectiveState
..clear()
..addAll(stored.contexts);
_publishableContextIds
..clear()
..addAll(stored.publishableContextIds);
_contextSourceCreatedAt
..clear()
..addAll(stored.sourceCreatedAt);
_persistLocalState();
}
void _persistLocalState() {
_storage.write(
pubkey,
_effectiveState,
_publishableContextIds,
_contextSourceCreatedAt,
);
}
void _rotateSlotId() {
_slotId = generateReadStateSlotId();
_storage.writeSlotId(pubkey, _slotId);
}
bool _isPlausibleCreatedAt(int createdAt) =>
createdAt <= currentUnixSeconds() + readStateMaxClockDriftSeconds;
bool _isOversizedReadStateError(Object error) {
final msg = error.toString().toLowerCase();
return error is ArgumentError &&
msg.contains('plaintext must be 1-65535 bytes');
}
bool _isPermanentReadStateRemoteError(Object error) {
// Relay rejections come back as `Exception("<message>")` from the
// websocket OK handler. Pattern-match on the message text since we no
// longer have HTTP status codes.
final msg = error.toString().toLowerCase();
return msg.contains('unknown event kind') ||
msg.contains('missing users:write') ||
msg.contains('insufficient scope') ||
msg.contains('restricted: unknown');
}
}
@@ -0,0 +1,246 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../relay/relay.dart';
import '../theme/theme_provider.dart';
import '../community/community_provider.dart';
import 'read_state_manager.dart';
class ReadStateState {
final bool isReady;
final String? pubkey;
final Map<String, int> contexts;
final int version;
/// Session-local forced-unread flags, keyed by the forced context id
/// (a channel id from the channel tile, or a `msg:` key from the message
/// actions sheet), each mapped to the channel it belongs to so channel
/// tiles and badges can surface message-level forces.
final Map<String, String> forcedUnreadContexts;
const ReadStateState({
required this.isReady,
required this.pubkey,
required this.contexts,
required this.version,
this.forcedUnreadContexts = const {},
});
const ReadStateState.inert()
: isReady = false,
pubkey = null,
contexts = const {},
version = 0,
forcedUnreadContexts = const {};
/// Channels that should surface as unread because of a forced-unread flag —
/// either forced directly, or containing a forced message.
Set<String> get locallyForcedChannelIds =>
forcedUnreadContexts.values.toSet();
/// Whether this exact context (channel id or `msg:` key) is forced unread.
bool isForcedUnread(String contextId) =>
forcedUnreadContexts.containsKey(contextId);
int? effectiveTimestamp(String contextId) => contexts[contextId];
ReadStateState copyWithContext(String contextId, int timestamp) {
final current = contexts[contextId] ?? 0;
if (timestamp <= current) {
return this;
}
return ReadStateState(
isReady: isReady,
pubkey: pubkey,
contexts: Map.unmodifiable({...contexts, contextId: timestamp}),
version: version + 1,
forcedUnreadContexts: forcedUnreadContexts,
);
}
}
class ReadStateNotifier extends Notifier<ReadStateState> {
ReadStateManager? _manager;
bool _isInitialized = false;
final Map<String, String> _forcedUnreadContexts = {};
@override
ReadStateState build() {
_manager?.dispose(flushPending: false);
_manager = null;
_isInitialized = false;
_forcedUnreadContexts.clear();
final relayConfig = ref.watch(relayConfigProvider);
ref.watch(relaySessionProvider);
final activeCommunity = ref.watch(activeCommunityProvider).value;
final nsec = relayConfig.nsec?.trim();
if (nsec == null || nsec.isEmpty) {
return const ReadStateState.inert();
}
final signedRelay = SignedEventRelay(
session: ref.read(relaySessionProvider.notifier),
nsec: nsec,
);
final pubkey =
_normalizePubkey(activeCommunity?.pubkey) ??
_safeDerivedPubkey(signedRelay);
if (pubkey == null) {
return const ReadStateState.inert();
}
final crypto = ReadStateCrypto.tryCreate(nsec: nsec, pubkey: pubkey);
if (crypto == null) {
return const ReadStateState.inert();
}
final prefs = ref.read(savedPrefsProvider);
late final ReadStateManager manager;
manager = ReadStateManager(
pubkey: pubkey,
prefs: prefs,
crypto: crypto,
relaySession: ref.read(relaySessionProvider.notifier),
signedEventRelay: signedRelay,
remoteEnabled: true,
onChanged: () => _emitManagerState(manager),
);
_manager = manager;
ref.onDispose(() {
manager.dispose();
if (_manager == manager) {
_manager = null;
}
});
ref.listen(appLifecycleProvider, (_, next) {
if (next == AppLifecycleState.paused ||
next == AppLifecycleState.detached ||
next == AppLifecycleState.hidden) {
unawaited(manager.flush());
}
});
ref.listen(relaySessionProvider, (prev, next) {
if (prev?.status != SessionStatus.connected &&
next.status == SessionStatus.connected) {
unawaited(manager.reinitializeRemote());
}
});
Future.microtask(() async {
await manager.initialize();
if (_manager != manager) return;
_isInitialized = true;
_emitManagerState(manager);
});
return _stateFromManager(manager, isReady: false);
}
/// Advance a context's read marker. Clears the forced-unread flag for
/// exactly this context, if any. An explicit channel-level "Mark read"
/// (channel tile/menu) should pass [clearForcedMessages] so message-level
/// forces inside the channel are released too; the automatic read on
/// channel open must not, so a message deliberately marked unread stays
/// unread until acted on.
void markContextRead(
String contextId,
int unixTimestamp, {
bool clearForcedMessages = false,
}) {
var removed = _forcedUnreadContexts.remove(contextId) != null;
if (clearForcedMessages) {
final before = _forcedUnreadContexts.length;
_forcedUnreadContexts.removeWhere(
(_, channelId) => channelId == contextId,
);
removed = removed || _forcedUnreadContexts.length != before;
}
_manager?.markContextRead(contextId, unixTimestamp);
if (removed) {
_refreshForcedState();
}
}
/// Force a context unread for the rest of the session. [contextId] is a
/// channel id (channel-tile action) or a `msg:` key (message actions
/// sheet); [channelId] is the channel the context belongs to, so tiles and
/// badges can surface message-level forces. Read markers are monotonic,
/// so this is the only way to move a context back to unread.
void markContextUnread(String contextId, {required String channelId}) {
if (_manager == null) return;
_forcedUnreadContexts[contextId] = channelId;
_refreshForcedState();
}
void _refreshForcedState() {
final manager = _manager;
if (manager == null) return;
state = _stateFromManager(
manager,
isReady: _isInitialized,
previousVersion: state.version,
);
}
void seedContextRead(String contextId, int unixTimestamp) {
_manager?.seedContextRead(contextId, unixTimestamp);
}
void _emitManagerState(ReadStateManager manager) {
if (_manager != manager) return;
final advances = manager.drainSyncedAdvances();
for (final contextId in advances) {
_forcedUnreadContexts.remove(contextId);
}
state = _stateFromManager(
manager,
isReady: _isInitialized,
previousVersion: state.version,
);
}
ReadStateState _stateFromManager(
ReadStateManager manager, {
required bool isReady,
int? previousVersion,
}) {
return ReadStateState(
isReady: isReady,
pubkey: manager.pubkey,
contexts: manager.effectiveContexts,
version: (previousVersion ?? 0) + 1,
forcedUnreadContexts: Map.unmodifiable(
Map<String, String>.from(_forcedUnreadContexts),
),
);
}
}
final readStateProvider = NotifierProvider<ReadStateNotifier, ReadStateState>(
ReadStateNotifier.new,
);
String? _normalizePubkey(String? value) {
final normalized = value?.trim().toLowerCase();
if (normalized == null || normalized.isEmpty) {
return null;
}
return normalized;
}
String? _safeDerivedPubkey(SignedEventRelay relay) {
try {
return _normalizePubkey(relay.pubkey);
} catch (e) {
debugPrint('[ReadStateManager] pubkey derivation failed: $e');
return null;
}
}
@@ -0,0 +1,208 @@
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
import 'read_state_format.dart';
import 'read_state_time.dart';
const _clientIdKeyPrefix = 'buzz.nip-rs.client-id';
const _slotIdKeyPrefix = 'buzz.nip-rs.slot-id';
const _uuid = Uuid();
String localReadStateKey(String pubkey) => 'buzz.channel-read-state.v2:$pubkey';
String localPublishableContextKey(String pubkey) =>
'buzz.channel-read-state.publishable.v1:$pubkey';
String localSourceCreatedAtKey(String pubkey) =>
'buzz.channel-read-state.source-created-at.v1:$pubkey';
String clientIdKey(String pubkey) => '$_clientIdKeyPrefix:$pubkey';
String slotIdKey(String pubkey) => '$_slotIdKeyPrefix:$pubkey';
class StoredReadState {
final Map<String, int> contexts;
final Set<String> publishableContextIds;
final Map<String, int> sourceCreatedAt;
StoredReadState({
required Map<String, int> contexts,
required Set<String> publishableContextIds,
required Map<String, int> sourceCreatedAt,
}) : contexts = Map.unmodifiable(contexts),
publishableContextIds = Set.unmodifiable(publishableContextIds),
sourceCreatedAt = Map.unmodifiable(sourceCreatedAt);
}
class ReadStateStorage {
final SharedPreferences _prefs;
ReadStateStorage(this._prefs);
String getOrCreateClientId(String pubkey) {
final key = clientIdKey(pubkey);
final stored = _prefs.getString(key);
if (_isValidClientId(stored)) {
return stored!;
}
final generated = _uuid.v4();
_prefs.setString(key, generated);
return generated;
}
String getOrCreateSlotId(String pubkey) {
final key = slotIdKey(pubkey);
final stored = _prefs.getString(key);
if (_isValidSlotId(stored)) {
return stored!;
}
final generated = generateReadStateSlotId();
_prefs.setString(key, generated);
return generated;
}
void writeSlotId(String pubkey, String slotId) {
_prefs.setString(slotIdKey(pubkey), slotId);
}
StoredReadState read(String pubkey) {
return StoredReadState(
contexts: _readContexts(pubkey),
publishableContextIds: _readPublishableContextIds(pubkey),
sourceCreatedAt: _readSourceCreatedAt(pubkey),
);
}
void write(
String pubkey,
Map<String, int> contexts,
Set<String> publishableContextIds,
Map<String, int> sourceCreatedAt,
) {
final state = <String, String>{};
for (final entry in contexts.entries) {
state[entry.key] = unixSecondsToDateTime(entry.value).toIso8601String();
}
_prefs.setString(localReadStateKey(pubkey), jsonEncode(state));
_prefs.setString(
localPublishableContextKey(pubkey),
jsonEncode(publishableContextIds.toList()),
);
_prefs.setString(
localSourceCreatedAtKey(pubkey),
jsonEncode(sourceCreatedAt.map((k, v) => MapEntry(k, v.toString()))),
);
}
Map<String, int> _readContexts(String pubkey) {
final raw = _prefs.getString(localReadStateKey(pubkey));
if (raw == null || raw.isEmpty) {
return {};
}
final Object? parsed;
try {
parsed = jsonDecode(raw);
} catch (e) {
debugPrint('[ReadStateManager] storage: contexts JSON corrupt: $e');
return {};
}
final record = asStringObjectMap(parsed);
if (record == null) {
return {};
}
final contexts = <String, int>{};
for (final entry in record.entries) {
final timestamp = isoToUnixSeconds(entry.value);
if (timestamp == null) continue;
final current = contexts[entry.key] ?? 0;
if (timestamp > current) {
contexts[entry.key] = timestamp;
}
}
return contexts;
}
Set<String> _readPublishableContextIds(String pubkey) {
final raw = _prefs.getString(localPublishableContextKey(pubkey));
if (raw == null || raw.isEmpty) {
return {};
}
final Object? parsed;
try {
parsed = jsonDecode(raw);
} catch (e) {
debugPrint(
'[ReadStateManager] storage: publishableContextIds JSON corrupt: $e',
);
return {};
}
if (parsed is! List) {
return {};
}
return {
for (final value in parsed)
if (value is String) value,
};
}
Map<String, int> _readSourceCreatedAt(String pubkey) {
final raw = _prefs.getString(localSourceCreatedAtKey(pubkey));
if (raw == null || raw.isEmpty) return {};
final Object? parsed;
try {
parsed = jsonDecode(raw);
} catch (e) {
debugPrint(
'[ReadStateManager] storage: sourceCreatedAt JSON corrupt: $e',
);
return {};
}
final record = asStringObjectMap(parsed);
if (record == null) return {};
final result = <String, int>{};
for (final entry in record.entries) {
final value = entry.value;
if (value is int) {
result[entry.key] = value;
} else if (value is String) {
final parsed = int.tryParse(value);
if (parsed != null) result[entry.key] = parsed;
}
}
return result;
}
}
String generateReadStateSlotId() {
final random = Random.secure();
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
}
bool _isValidClientId(String? value) {
return value != null && value.isNotEmpty && value.runes.length <= 64;
}
bool _isValidSlotId(String? value) {
if (value == null || value.isEmpty || value.length > 64) {
return false;
}
return isValidReadStateDTag('$readStateDTagPrefix$value');
}
@@ -0,0 +1,25 @@
const _millisecondsPerSecond = 1000;
int? dateTimeToUnixSeconds(DateTime? value) {
if (value == null) return null;
return value.millisecondsSinceEpoch ~/ _millisecondsPerSecond;
}
int currentUnixSeconds() => dateTimeToUnixSeconds(DateTime.now())!;
DateTime unixSecondsToDateTime(int value) {
return DateTime.fromMillisecondsSinceEpoch(
value * _millisecondsPerSecond,
isUtc: true,
);
}
int? isoToUnixSeconds(Object? value) {
if (value is! String || value.isEmpty) {
return null;
}
return dateTimeToUnixSeconds(DateTime.tryParse(value));
}
const readStateMaxClockDriftSeconds = 300;
@@ -0,0 +1,443 @@
import 'dart:convert';
import 'dart:typed_data';
const _pngSignature = <int>[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
const _allowedPngAncillaryChunks = {
'cHRM',
'gAMA',
'sBIT',
'sRGB',
'bKGD',
'hIST',
'tRNS',
'sPLT',
'acTL',
'fcTL',
'fdAT',
};
const _allowedWebpChunks = {'VP8 ', 'VP8L', 'VP8X', 'ALPH', 'ANIM', 'ANMF'};
const _webpMetadataFlags = 0x20 | 0x08 | 0x04;
/// Remove metadata from an animated image without decoding its frames.
///
/// Decoding through UIKit or Android Bitmap would flatten animations. These
/// structural scrubbers retain only the chunks/extensions accepted by the
/// relay and preserve animation timing, disposal, looping, and frame data.
Uint8List sanitizeAnimatedImageForUpload(Uint8List bytes, String mimeType) {
return switch (mimeType) {
'image/gif' => _scrubGif(bytes),
'image/png' => _scrubPng(bytes),
'image/webp' => _scrubWebp(bytes),
_ => throw FormatException('Unsupported animated image type: $mimeType'),
};
}
Uint8List _scrubPng(Uint8List bytes) {
if (!_startsWith(bytes, _pngSignature)) {
throw const FormatException('Invalid PNG signature');
}
final output = BytesBuilder(copy: false)..add(_pngSignature);
var offset = _pngSignature.length;
while (offset < bytes.length) {
if (bytes.length - offset < 12) {
throw const FormatException('Truncated PNG chunk');
}
final payloadLength = _readUint32BigEndian(bytes, offset);
final chunkLength = payloadLength + 12;
if (payloadLength > bytes.length - offset - 12) {
throw const FormatException('Invalid PNG chunk length');
}
final typeStart = offset + 4;
final type = ascii.decode(bytes.sublist(typeStart, typeStart + 4));
if (type == 'iCCP') {
throw const FormatException(
'Animated PNG ICC profile cannot be removed safely',
);
}
if (type == 'eXIf') {
final orientation = _readExifOrientation(
bytes,
offset + 8,
payloadLength,
);
if (orientation != null && orientation >= 2 && orientation <= 8) {
throw const FormatException(
'Animated PNG EXIF orientation cannot be removed safely',
);
}
}
final isAncillary = bytes[typeStart] & 0x20 != 0;
if (!isAncillary || _allowedPngAncillaryChunks.contains(type)) {
output.add(Uint8List.sublistView(bytes, offset, offset + chunkLength));
}
offset += chunkLength;
if (type == 'IEND') {
return output.takeBytes();
}
}
throw const FormatException('PNG is missing IEND');
}
Uint8List _scrubWebp(Uint8List bytes) {
if (bytes.length < 12 ||
!_matchesAscii(bytes, 0, 'RIFF') ||
!_matchesAscii(bytes, 8, 'WEBP')) {
throw const FormatException('Invalid WebP signature');
}
final declaredLength = _readUint32LittleEndian(bytes, 4);
final inputEnd = declaredLength + 8;
if (inputEnd < 12 || inputEnd > bytes.length) {
throw const FormatException('Invalid WebP container length');
}
final chunks = BytesBuilder(copy: false);
var offset = 12;
while (offset < inputEnd) {
if (inputEnd - offset < 8) {
throw const FormatException('Truncated WebP chunk');
}
final type = ascii.decode(bytes.sublist(offset, offset + 4));
final payloadLength = _readUint32LittleEndian(bytes, offset + 4);
final payloadStart = offset + 8;
final paddedLength = payloadLength + (payloadLength.isOdd ? 1 : 0);
final chunkEnd = payloadStart + paddedLength;
if (chunkEnd > inputEnd) {
throw const FormatException('Invalid WebP chunk length');
}
if (type == 'EXIF') {
final orientation = _readExifOrientation(
bytes,
payloadStart,
payloadLength,
);
if (orientation != null && orientation >= 2 && orientation <= 8) {
throw const FormatException(
'Animated WebP EXIF orientation cannot be removed safely',
);
}
}
if (type == 'ICCP') {
throw const FormatException(
'Animated WebP ICC profile cannot be removed safely',
);
}
if (_allowedWebpChunks.contains(type)) {
if (type == 'VP8X') {
if (payloadLength == 0) {
throw const FormatException('Invalid VP8X chunk');
}
final payload = BytesBuilder(copy: false)
..addByte(bytes[payloadStart] & ~_webpMetadataFlags)
..add(
Uint8List.sublistView(
bytes,
payloadStart + 1,
payloadStart + payloadLength,
),
);
_addWebpChunk(chunks, type, payload.takeBytes());
} else if (type == 'ANMF') {
_addWebpChunk(
chunks,
type,
_scrubAnmfPayload(
Uint8List.sublistView(
bytes,
payloadStart,
payloadStart + payloadLength,
),
),
);
} else {
_addWebpChunk(
chunks,
type,
Uint8List.sublistView(
bytes,
payloadStart,
payloadStart + payloadLength,
),
);
}
}
offset = chunkEnd;
}
final chunkBytes = chunks.takeBytes();
final output = BytesBuilder(copy: false)
..add(ascii.encode('RIFF'))
..add(_uint32LittleEndian(chunkBytes.length + 4))
..add(ascii.encode('WEBP'))
..add(chunkBytes);
return output.takeBytes();
}
void _addWebpChunk(BytesBuilder output, String type, Uint8List payload) {
output
..add(ascii.encode(type))
..add(_uint32LittleEndian(payload.length))
..add(payload);
if (payload.length.isOdd) output.addByte(0);
}
Uint8List _scrubAnmfPayload(Uint8List payload) {
const frameHeaderLength = 16;
if (payload.length < frameHeaderLength) {
throw const FormatException('Invalid WebP animation frame');
}
final output = BytesBuilder(copy: false)
..add(Uint8List.sublistView(payload, 0, frameHeaderLength));
var offset = frameHeaderLength;
var sawAlpha = false;
var sawImage = false;
while (offset < payload.length) {
if (payload.length - offset < 8) {
throw const FormatException('Truncated WebP animation frame chunk');
}
final chunkLength = _readUint32LittleEndian(payload, offset + 4);
final chunkStart = offset + 8;
final paddedLength = chunkLength + (chunkLength.isOdd ? 1 : 0);
final chunkEnd = chunkStart + paddedLength;
if (chunkEnd > payload.length) {
throw const FormatException('Invalid WebP animation frame chunk length');
}
final chunkPayload = Uint8List.sublistView(
payload,
chunkStart,
chunkStart + chunkLength,
);
if (_matchesAscii(payload, offset, 'ALPH')) {
if (sawAlpha || sawImage) {
throw const FormatException('Invalid WebP animation frame layout');
}
_addWebpChunk(output, 'ALPH', chunkPayload);
sawAlpha = true;
} else if (_matchesAscii(payload, offset, 'VP8 ')) {
if (sawImage) {
throw const FormatException('Invalid WebP animation frame layout');
}
_addWebpChunk(output, 'VP8 ', chunkPayload);
sawImage = true;
} else if (_matchesAscii(payload, offset, 'VP8L')) {
if (sawAlpha || sawImage) {
throw const FormatException('Invalid WebP animation frame layout');
}
_addWebpChunk(output, 'VP8L', chunkPayload);
sawImage = true;
}
offset = chunkEnd;
}
if (!sawImage) {
throw const FormatException('WebP animation frame is missing image data');
}
return output.takeBytes();
}
int? _readExifOrientation(
Uint8List bytes,
int payloadStart,
int payloadLength,
) {
final payloadEnd = payloadStart + payloadLength;
var tiffStart = payloadStart;
if (payloadLength >= 6 &&
_matchesAscii(bytes, payloadStart, 'Exif') &&
bytes[payloadStart + 4] == 0 &&
bytes[payloadStart + 5] == 0) {
tiffStart += 6;
}
if (payloadEnd - tiffStart < 8) return null;
final endian = switch ((bytes[tiffStart], bytes[tiffStart + 1])) {
(0x49, 0x49) => Endian.little,
(0x4d, 0x4d) => Endian.big,
_ => null,
};
if (endian == null) return null;
int? readUint16(int offset) {
if (offset < tiffStart || offset + 2 > payloadEnd) return null;
return ByteData.sublistView(bytes, offset, offset + 2).getUint16(0, endian);
}
int? readUint32(int offset) {
if (offset < tiffStart || offset + 4 > payloadEnd) return null;
return ByteData.sublistView(bytes, offset, offset + 4).getUint32(0, endian);
}
if (readUint16(tiffStart + 2) != 42) return null;
final ifdOffset = readUint32(tiffStart + 4);
if (ifdOffset == null) return null;
final ifdStart = tiffStart + ifdOffset;
final entryCount = readUint16(ifdStart);
if (entryCount == null) return null;
final entriesStart = ifdStart + 2;
for (var index = 0; index < entryCount; index += 1) {
final entryStart = entriesStart + index * 12;
if (readUint16(entryStart) == 0x0112 &&
readUint16(entryStart + 2) == 3 &&
readUint32(entryStart + 4) == 1) {
return readUint16(entryStart + 8);
}
}
return null;
}
Uint8List _scrubGif(Uint8List bytes) {
if (bytes.length < 13 ||
(!_matchesAscii(bytes, 0, 'GIF87a') &&
!_matchesAscii(bytes, 0, 'GIF89a'))) {
throw const FormatException('Invalid GIF signature');
}
var offset = 13;
final packed = bytes[10];
if (packed & 0x80 != 0) {
final tableLength = 3 << ((packed & 0x07) + 1);
offset += tableLength;
if (offset > bytes.length) {
throw const FormatException('Truncated GIF color table');
}
}
final segments = <Uint8List?>[Uint8List.sublistView(bytes, 0, offset)];
final pendingGraphicControls = <int>[];
while (offset < bytes.length) {
switch (bytes[offset]) {
case 0x2c:
final start = offset;
if (bytes.length - offset < 10) {
throw const FormatException('Truncated GIF image descriptor');
}
final imagePacked = bytes[offset + 9];
offset += 10;
if (imagePacked & 0x80 != 0) {
offset += 3 << ((imagePacked & 0x07) + 1);
if (offset > bytes.length) {
throw const FormatException('Truncated GIF local color table');
}
}
if (offset >= bytes.length) {
throw const FormatException('Missing GIF LZW code size');
}
offset = _gifSubBlocksEnd(bytes, offset + 1);
segments.add(Uint8List.sublistView(bytes, start, offset));
pendingGraphicControls.clear();
case 0x21:
final start = offset;
if (bytes.length - offset < 2) {
throw const FormatException('Truncated GIF extension');
}
final label = bytes[offset + 1];
offset += 2;
switch (label) {
case 0xf9:
if (bytes.length - offset < 6 ||
bytes[offset] != 4 ||
bytes[offset + 5] != 0) {
throw const FormatException('Invalid GIF graphic control');
}
offset += 6;
segments.add(Uint8List.sublistView(bytes, start, offset));
pendingGraphicControls.add(segments.length - 1);
case 0xff:
if (bytes.length - offset < 12 || bytes[offset] != 11) {
throw const FormatException('Invalid GIF application extension');
}
final isLoopExtension =
_matchesAscii(bytes, offset + 1, 'NETSCAPE2.0') ||
_matchesAscii(bytes, offset + 1, 'ANIMEXTS1.0');
final dataStart = offset + 12;
offset = _gifSubBlocksEnd(bytes, dataStart);
if (isLoopExtension) {
if (bytes.length - dataStart < 5 ||
bytes[dataStart] != 3 ||
bytes[dataStart + 1] != 1) {
throw const FormatException('Invalid GIF loop extension');
}
segments
..add(Uint8List.sublistView(bytes, start, dataStart + 4))
..add(Uint8List(1));
}
case 0x01:
offset = _gifSubBlocksEnd(bytes, offset);
for (final segmentIndex in pendingGraphicControls) {
segments[segmentIndex] = null;
}
pendingGraphicControls.clear();
default:
offset = _gifSubBlocksEnd(bytes, offset);
}
case 0x3b:
segments.add(Uint8List.sublistView(bytes, offset, offset + 1));
final output = BytesBuilder(copy: false);
for (final segment in segments) {
if (segment != null) output.add(segment);
}
return output.takeBytes();
default:
throw const FormatException('Invalid GIF block');
}
}
throw const FormatException('GIF is missing trailer');
}
int _gifSubBlocksEnd(Uint8List bytes, int offset) {
while (offset < bytes.length) {
final blockLength = bytes[offset];
offset += 1;
if (blockLength == 0) return offset;
offset += blockLength;
if (offset > bytes.length) {
throw const FormatException('Truncated GIF data block');
}
}
throw const FormatException('GIF data blocks are missing a terminator');
}
bool _startsWith(Uint8List bytes, List<int> prefix) {
if (bytes.length < prefix.length) return false;
for (var index = 0; index < prefix.length; index += 1) {
if (bytes[index] != prefix[index]) return false;
}
return true;
}
bool _matchesAscii(Uint8List bytes, int offset, String value) {
final expected = ascii.encode(value);
if (bytes.length - offset < expected.length) return false;
for (var index = 0; index < expected.length; index += 1) {
if (bytes[offset + index] != expected[index]) return false;
}
return true;
}
int _readUint32BigEndian(Uint8List bytes, int offset) {
return ByteData.sublistView(bytes, offset, offset + 4).getUint32(0);
}
int _readUint32LittleEndian(Uint8List bytes, int offset) {
return ByteData.sublistView(
bytes,
offset,
offset + 4,
).getUint32(0, Endian.little);
}
Uint8List _uint32LittleEndian(int value) {
return Uint8List(4)..buffer.asByteData().setUint32(0, value, Endian.little);
}
@@ -0,0 +1,55 @@
import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'relay_session.dart';
/// Tracks the app lifecycle state and drives websocket connect/disconnect
/// behavior for mobile battery efficiency.
class AppLifecycleNotifier extends Notifier<AppLifecycleState> {
AppLifecycleListener? _listener;
StreamSubscription<List<ConnectivityResult>>? _connectivitySub;
@override
AppLifecycleState build() {
_listener = AppLifecycleListener(onStateChange: _onStateChange);
// Watch connectivity changes to trigger reconnect on network restore.
_connectivitySub = Connectivity().onConnectivityChanged.listen((results) {
final hasNetwork = results.any((r) => r != ConnectivityResult.none);
if (hasNetwork && state == AppLifecycleState.resumed) {
ref.read(relaySessionProvider.notifier).onAppResumed();
}
});
ref.onDispose(() {
_listener?.dispose();
_connectivitySub?.cancel();
});
return AppLifecycleState.resumed;
}
void _onStateChange(AppLifecycleState newState) {
state = newState;
final session = ref.read(relaySessionProvider.notifier);
switch (newState) {
case AppLifecycleState.resumed:
session.onAppResumed();
case AppLifecycleState.paused:
case AppLifecycleState.detached:
session.onAppPaused();
case AppLifecycleState.inactive:
case AppLifecycleState.hidden:
break; // Brief transition states — no action.
}
}
}
final appLifecycleProvider =
NotifierProvider<AppLifecycleNotifier, AppLifecycleState>(
AppLifecycleNotifier.new,
);
@@ -0,0 +1,50 @@
import 'dart:async';
import 'package:shared_preferences/shared_preferences.dart';
/// Reads an identity-scoped preference, migrating values left under a
/// pre-canonicalization relay origin.
///
/// Identity-scoped keys embed the relay origin, and that origin used to be
/// whatever scheme the onboarding flow happened to persist — `wss://` for an
/// invite join, `https://` for device pairing. Now that [RelayConfig.baseUrl]
/// canonicalizes the scheme, an install that joined by invite would compute a
/// different key than the one its data was written under, leaving that data on
/// disk but unreachable.
///
/// The value under [canonicalKey] wins. Otherwise a value under [legacyKey] is
/// returned straight away and promoted onto the canonical key in the
/// background. The legacy entry is removed only once the copy reports success,
/// so a migration interrupted mid-flight leaves the original readable and the
/// next read simply retries.
///
/// Pass matching accessors for the stored type — `getString`/`setString`, or
/// `getStringList`/`setStringList`.
T? readMigratedPref<T>(
SharedPreferences prefs, {
required String canonicalKey,
required String legacyKey,
required T? Function(String key) read,
required Future<bool> Function(String key, T value) write,
}) {
final canonical = read(canonicalKey);
if (canonical != null) return canonical;
// Pairing-created communities already store an HTTP origin, so the two keys
// coincide and there is nothing to migrate.
if (canonicalKey == legacyKey) return null;
final legacy = read(legacyKey);
if (legacy == null) return null;
unawaited(_promote(prefs, legacyKey, write(canonicalKey, legacy)));
return legacy;
}
Future<void> _promote(
SharedPreferences prefs,
String legacyKey,
Future<bool> copy,
) async {
if (await copy) await prefs.remove(legacyKey);
}
+164
View File
@@ -0,0 +1,164 @@
import 'dart:convert';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'relay_provider.dart';
const _mediaGetAuthKind = 24242;
const _mediaGetAuthLifetimeSeconds = 600;
/// Re-sign this long before the cached auth event expires, so an in-flight
/// request signed just before the boundary still lands well within validity.
const _mediaGetAuthRefreshMarginSeconds = 60;
/// Builds BUD-01 Blossom `t=get` auth headers for relay-host media URLs.
///
/// Returns an empty map for non-relay URLs or when no signing key is available,
/// so callers can safely use this on arbitrary profile/custom-emoji URLs without
/// leaking Buzz credentials to third-party hosts.
///
/// The signed header is memoized until [_mediaGetAuthRefreshMarginSeconds]
/// before expiry: repeated calls return the byte-identical map instead of
/// producing a fresh Schnorr signature per widget build. The service itself is
/// rebuilt (dropping the memo) whenever the relay config — base URL or signing
/// identity — changes, via [mediaGetAuthServiceProvider].
class MediaGetAuthService {
final String _baseUrl;
final String? _nsec;
final DateTime Function() _now;
Map<String, String>? _cachedHeaders;
DateTime? _refreshAt;
MediaGetAuthService({
required String baseUrl,
required String? nsec,
DateTime Function()? now,
}) : _baseUrl = baseUrl,
_nsec = nsec,
_now = now ?? DateTime.now;
bool isRelayMediaUrl(String url) {
final uri = Uri.tryParse(url);
final relayUri = Uri.tryParse(_baseUrl);
if (uri == null || relayUri == null) return false;
return _isRelayMediaUrl(uri, relayUri);
}
Map<String, String> headersFor(String url) {
final nsec = _nsec;
if (nsec == null || nsec.isEmpty) return const {};
if (!isRelayMediaUrl(url)) return const {};
final cached = _cachedHeaders;
final refreshAt = _refreshAt;
if (cached != null && refreshAt != null && _now().isBefore(refreshAt)) {
return cached;
}
try {
final signedAt = _now();
final authEvent = _buildGetAuthEvent(nsec);
final encoded = base64Url
.encode(utf8.encode(authEvent.toJson()))
.replaceAll('=', '');
final headers = Map<String, String>.unmodifiable({
'Authorization': 'Nostr $encoded',
});
_cachedHeaders = headers;
_refreshAt = signedAt.add(
const Duration(
seconds:
_mediaGetAuthLifetimeSeconds - _mediaGetAuthRefreshMarginSeconds,
),
);
return headers;
} catch (_) {
// Read auth is best-effort: while the relay rollout flag is off, an
// unsigned fetch still works. Once the flag is on, this request will 403
// instead of crashing the widget tree because local key material is bad.
return const {};
}
}
bool _isRelayMediaUrl(Uri uri, Uri relayUri) {
if (uri.scheme != 'http' && uri.scheme != 'https') return false;
if (uri.host.isEmpty || relayUri.host.isEmpty) return false;
// Extract the URL's origin and path. Query strings are ignored for media
// host/path detection, matching the fetch target shape used by descriptors.
final base = '${uri.scheme}://${uri.authority}';
final mediaAuthority = extractServerAuthority(base);
final relayAuthority = extractServerAuthority(_baseUrl);
if (mediaAuthority == null || relayAuthority == null) return false;
if (mediaAuthority.toLowerCase() != relayAuthority.toLowerCase()) {
return false;
}
return uri.path.startsWith('/media/');
}
nostr.Event _buildGetAuthEvent(String nsec) {
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
if (privkeyHex.isEmpty) {
throw Exception('Invalid nsec');
}
final expiration =
(_now().millisecondsSinceEpoch ~/ 1000) + _mediaGetAuthLifetimeSeconds;
final tags = <List<String>>[
['t', 'get'],
['expiration', '$expiration'],
if (extractServerAuthority(_baseUrl) case final authority?)
['server', authority],
];
return nostr.Event.from(
kind: _mediaGetAuthKind,
content: 'Get buzz-media',
tags: tags,
secretKey: privkeyHex,
verify: false,
);
}
}
final mediaGetAuthServiceProvider = Provider<MediaGetAuthService>((ref) {
final config = ref.watch(relayConfigProvider);
return MediaGetAuthService(baseUrl: config.baseUrl, nsec: config.nsec);
});
Map<String, String> mediaGetHeadersFor(WidgetRef ref, String url) {
return ref.read(mediaGetAuthServiceProvider).headersFor(url);
}
Map<String, String> mediaGetHeadersForContext(
BuildContext context,
String url,
) {
final container = ProviderScope.containerOf(context, listen: false);
return container.read(mediaGetAuthServiceProvider).headersFor(url);
}
String? extractServerAuthority(String baseUrl) {
final uri = Uri.parse(baseUrl);
if (uri.host.isEmpty) return null;
final host = uri.host.contains(':') ? '[${uri.host}]' : uri.host;
final port = uri.hasPort ? uri.port : null;
final authority = port == null ? host : '$host:$port';
return _normalizeAuthority(authority);
}
String _normalizeAuthority(String authority) {
var normalized = authority.trim().toLowerCase();
if (normalized.endsWith('.')) {
normalized = normalized.substring(0, normalized.length - 1);
}
if (normalized.endsWith(':443')) {
return normalized.substring(0, normalized.length - ':443'.length);
}
if (normalized.endsWith(':80')) {
return normalized.substring(0, normalized.length - ':80'.length);
}
return normalized;
}
+238
View File
@@ -0,0 +1,238 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
import 'media_auth.dart';
/// Shared, keep-alive HTTP client for media fetches. One client per container
/// so TLS connections are reused across images instead of re-handshaking per
/// fetch. Override in tests to stub the network.
final mediaHttpClientProvider = Provider<http.Client>((ref) {
final client = http.Client();
ref.onDispose(client.close);
return client;
});
/// An [ImageProvider] for relay-hosted (and arbitrary remote) media that fixes
/// the fetch-stampede failure modes of `Image.network` + per-build auth
/// headers:
///
/// 1. **Stable cache identity.** Flutter's [NetworkImage] includes the full
/// headers map in its `==`/`hashCode`, so a freshly signed Authorization
/// header on every widget build made the *same URL* a new cache key and
/// bypassed the in-memory [ImageCache] entirely. This provider keys on
/// (url, scale, auth scope) and injects auth headers at *fetch* time, so
/// header refreshes never invalidate cached images.
/// 2. **Failure cooldown.** A URL that fails to load is not retried until a
/// cooldown elapses (honoring `Retry-After` on 429), so error retries on
/// rebuild cannot amplify into a request storm against the rate limiter.
///
/// The auth scope (relay base URL + signing identity) is part of key equality:
/// switching relay or account can never reuse another identity's cached bytes.
class MediaImageProvider extends ImageProvider<MediaImageProvider> {
final String url;
final double scale;
final MediaGetAuthService auth;
/// Excluded from equality: transport, not identity.
final http.Client client;
const MediaImageProvider({
required this.url,
required this.auth,
required this.client,
this.scale = 1.0,
});
static const _defaultCooldown = Duration(seconds: 30);
static const _maxRetryAfter = Duration(minutes: 5);
static final Map<String, DateTime> _cooldownUntil = {};
/// Injectable clock for cooldown tests.
@visibleForTesting
static DateTime Function() debugNow = DateTime.now;
@visibleForTesting
static void debugResetCooldowns() => _cooldownUntil.clear();
@override
Future<MediaImageProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture<MediaImageProvider>(this);
}
@override
ImageStreamCompleter loadImage(
MediaImageProvider key,
ImageDecoderCallback decode,
) {
return MultiFrameImageStreamCompleter(
codec: _loadAsync(key, decode),
scale: key.scale,
debugLabel: url,
);
}
Future<ui.Codec> _loadAsync(
MediaImageProvider key,
ImageDecoderCallback decode,
) async {
try {
final until = _cooldownUntil[url];
if (until != null) {
if (debugNow().isBefore(until)) {
throw MediaImageCooldownException(url: url, until: until);
}
_cooldownUntil.remove(url);
}
final uri = Uri.parse(url);
final http.Response response;
try {
response = await client.get(uri, headers: auth.headersFor(url));
} catch (_) {
_cooldownUntil[url] = debugNow().add(_defaultCooldown);
rethrow;
}
if (response.statusCode != 200) {
_cooldownUntil[url] = debugNow().add(_cooldownFor(response));
throw NetworkImageLoadException(
statusCode: response.statusCode,
uri: uri,
);
}
final bytes = response.bodyBytes;
if (bytes.isEmpty) {
_cooldownUntil[url] = debugNow().add(_defaultCooldown);
throw NetworkImageLoadException(statusCode: 200, uri: uri);
}
final buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
return decode(buffer);
} catch (_) {
// Match NetworkImage: make sure an errored key is not retained in the
// cache. The cooldown map (not the cache) throttles retries.
scheduleMicrotask(() {
PaintingBinding.instance.imageCache.evict(key);
});
rethrow;
}
}
Duration _cooldownFor(http.Response response) {
final retryAfter = int.tryParse(response.headers['retry-after'] ?? '');
if (retryAfter != null && retryAfter > 0) {
final requested = Duration(seconds: retryAfter);
return requested > _maxRetryAfter ? _maxRetryAfter : requested;
}
return _defaultCooldown;
}
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is MediaImageProvider &&
other.url == url &&
other.scale == scale &&
other.auth == auth;
}
@override
int get hashCode => Object.hash(url, scale, auth);
@override
String toString() =>
'MediaImageProvider("$url", scale: ${scale.toStringAsFixed(1)})';
}
/// Thrown when a fetch is suppressed because the URL recently failed.
class MediaImageCooldownException implements Exception {
final String url;
final DateTime until;
const MediaImageCooldownException({required this.url, required this.until});
@override
String toString() =>
'MediaImageCooldownException: $url is cooling down until $until';
}
/// Drop-in replacement for the media `Image.network` call sites.
///
/// Renders [url] through [MediaImageProvider] (stable cache key, fetch-time
/// auth, failure cooldown) and bounds the decode size so a handful of large
/// originals cannot thrash the global [ImageCache]:
///
/// - [decodeWidth] set: decode at that logical width (x device pixel ratio).
/// - otherwise, when [boundDecodeToLayout] is true (default): decode at the
/// layout width reported by [LayoutBuilder], when finite.
/// - [boundDecodeToLayout] false and no [decodeWidth]: full-resolution decode
/// (the zoomable full-screen viewer).
class MediaImage extends ConsumerWidget {
final String url;
final BoxFit? fit;
final double? width;
final double? height;
final String? semanticLabel;
final ImageErrorWidgetBuilder? errorBuilder;
final FilterQuality filterQuality;
final double? decodeWidth;
final bool boundDecodeToLayout;
const MediaImage({
super.key,
required this.url,
this.fit,
this.width,
this.height,
this.semanticLabel,
this.errorBuilder,
this.filterQuality = FilterQuality.medium,
this.decodeWidth,
this.boundDecodeToLayout = true,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final provider = MediaImageProvider(
url: url,
auth: ref.watch(mediaGetAuthServiceProvider),
client: ref.watch(mediaHttpClientProvider),
);
if (decodeWidth != null) {
return _image(provider, _physicalWidth(context, decodeWidth!));
}
if (!boundDecodeToLayout) {
return _image(provider, null);
}
return LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth;
final cacheWidth = maxWidth.isFinite && maxWidth > 0
? _physicalWidth(context, maxWidth)
: null;
return _image(provider, cacheWidth);
},
);
}
int _physicalWidth(BuildContext context, double logicalWidth) {
return (logicalWidth * MediaQuery.devicePixelRatioOf(context)).ceil();
}
Widget _image(MediaImageProvider provider, int? cacheWidth) {
return Image(
image: ResizeImage.resizeIfNeeded(cacheWidth, null, provider),
fit: fit,
width: width,
height: height,
semanticLabel: semanticLabel,
errorBuilder: errorBuilder,
filterQuality: filterQuality,
gaplessPlayback: true,
);
}
}
+999
View File
@@ -0,0 +1,999 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
import 'package:file_selector/file_selector.dart' as file_selector;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:image_picker/image_picker.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:pointycastle/digests/sha256.dart';
import 'animated_image_sanitizer.dart';
import 'media_auth.dart';
import 'mp4_fast_start.dart';
import 'relay_provider.dart';
const _mediaUploadPath = '/upload';
const _legacyMediaUploadPath = '/media/upload';
const _mediaUploadPlatformChannelName = 'buzz/media_upload';
const _sanitizeImageForUploadMethod = 'sanitizeImageForUpload';
const _transcodeVideoToMp4Method = 'transcodeVideoToMp4';
const _generateVideoPosterMethod = 'generateVideoPoster';
const _transcodeImageToJpegMethod = 'transcodeImageToJpeg';
const _requiresLegacyMediaStoragePermissionMethod =
'requiresLegacyMediaStoragePermission';
const _readClipboardImageMethod = 'readClipboardImage';
const _clipboardHasImageMethod = 'clipboardHasImage';
const _uploadAuthKind = 24242;
const _uploadAuthLifetimeSeconds = 300;
const _heicBrands = {
'heic',
'heix',
'hevc',
'hevx',
'heim',
'heis',
'mif1',
'msf1',
};
final _mediaUploadPlatformChannel = MethodChannel(
_mediaUploadPlatformChannelName,
);
/// Whether saving media needs Android's pre-scoped-storage runtime permission.
Future<bool> requiresLegacyMediaStoragePermission() async {
if (defaultTargetPlatform != TargetPlatform.android) {
return false;
}
return await _mediaUploadPlatformChannel.invokeMethod<bool>(
_requiresLegacyMediaStoragePermissionMethod,
) ??
false;
}
const _allowedImageMimeTypes = {
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
};
const _allowedVideoMimeTypes = {'video/mp4'};
const _maxVideoSizeBytes = 100 * 1024 * 1024; // 100MB
const _maxFileSizeBytes = 100 * 1024 * 1024; // 100MB
const _mediaPolicyUploadMessage = "We couldn't prepare this image for upload.";
typedef PickGalleryImage = Future<XFile?> Function();
/// Selects multiple gallery images for upload in picker order.
typedef PickGalleryImages = Future<List<XFile>> Function();
typedef PickGalleryVideo = Future<XFile?> Function();
typedef PickAttachmentFile = Future<XFile?> Function();
typedef SanitizeImageBytes =
Future<Uint8List> Function(Uint8List bytes, String mimeType);
typedef TranscodeImageToJpeg = Future<Uint8List> Function(Uint8List bytes);
typedef TranscodeVideoToMp4 = Future<String> Function(String filePath);
/// Generates poster-frame bytes for the video at [filePath], when available.
typedef GenerateVideoPoster = Future<Uint8List?> Function(String filePath);
typedef ReadClipboardImage = Future<Uint8List?> Function();
class MediaPolicyUploadException implements Exception {
const MediaPolicyUploadException();
@override
String toString() => _mediaPolicyUploadMessage;
}
/// Cancels a single user-initiated media upload without closing the shared
/// HTTP client used by later uploads.
class UploadCancellationToken {
final Completer<void> _cancelled = Completer<void>();
/// Whether cancellation has been requested.
bool get isCancelled => _cancelled.isCompleted;
/// Completes when cancellation is first requested.
Future<void> get whenCancelled => _cancelled.future;
/// Requests cancellation. Calling this more than once has no effect.
void cancel() {
if (!_cancelled.isCompleted) _cancelled.complete();
}
}
/// Indicates that a user cancelled a media upload before it completed.
class UploadCancelledException implements Exception {
const UploadCancelledException();
}
@immutable
class _PreparedUploadImage {
final Uint8List bytes;
final String mimeType;
const _PreparedUploadImage({required this.bytes, required this.mimeType});
}
@immutable
class BlobDescriptor {
final String url;
final String sha256;
final int size;
final String type;
final int uploaded;
final String? dim;
final String? blurhash;
final String? thumb;
final double? duration;
final String? image;
final String? filename;
const BlobDescriptor({
required this.url,
required this.sha256,
required this.size,
required this.type,
required this.uploaded,
this.dim,
this.blurhash,
this.thumb,
this.duration,
this.image,
this.filename,
});
factory BlobDescriptor.fromJson(Map<String, dynamic> json) => BlobDescriptor(
url: json['url'] as String,
sha256: json['sha256'] as String,
size: (json['size'] as num).toInt(),
type: json['type'] as String,
uploaded: (json['uploaded'] as num).toInt(),
dim: json['dim'] as String?,
blurhash: json['blurhash'] as String?,
thumb: json['thumb'] as String?,
duration: (json['duration'] as num?)?.toDouble(),
image: json['image'] as String?,
filename: json['filename'] as String?,
);
BlobDescriptor withFilename(String value) => BlobDescriptor(
url: url,
sha256: sha256,
size: size,
type: type,
uploaded: uploaded,
dim: dim,
blurhash: blurhash,
thumb: thumb,
duration: duration,
image: image,
filename: value,
);
/// Returns a descriptor with [value] as its NIP-71 video poster URL.
BlobDescriptor withImage(String value) => BlobDescriptor(
url: url,
sha256: sha256,
size: size,
type: type,
uploaded: uploaded,
dim: dim,
blurhash: blurhash,
thumb: thumb,
duration: duration,
image: value,
filename: filename,
);
List<String> toImetaTag() => [
'imeta',
'url $url',
'm $type',
'x $sha256',
'size $size',
if (dim != null) 'dim $dim',
if (blurhash != null) 'blurhash $blurhash',
if (thumb != null) 'thumb $thumb',
if (duration != null) 'duration $duration',
if (image != null) 'image $image',
if (filename != null) 'filename $filename',
];
String toMarkdownImage() {
if (type.startsWith('video/')) return '![video]($url)';
if (type.startsWith('image/')) return '![image]($url)';
final label = (filename ?? 'file').replaceAllMapped(
RegExp(r'[\\\[\]]'),
(match) => '\\${match[0]}',
);
return '[$label]($url)';
}
}
class MediaUploadService {
final String _baseUrl;
final String? _nsec;
final PickGalleryImage _pickGalleryImage;
final PickGalleryImages _pickGalleryImages;
final PickGalleryVideo _pickGalleryVideo;
final PickAttachmentFile? _pickAttachmentFile;
final SanitizeImageBytes _sanitizeImageBytes;
final TranscodeImageToJpeg _transcodeImageToJpeg;
final TranscodeVideoToMp4 _transcodeVideoToMp4;
final GenerateVideoPoster _generateVideoPoster;
final ReadClipboardImage _readClipboardImage;
final DateTime Function() _now;
final http.Client _http;
final bool _ownsHttpClient;
MediaUploadService({
required String baseUrl,
required String? nsec,
required PickGalleryImage pickGalleryImage,
PickGalleryImages? pickGalleryImages,
required PickGalleryVideo pickGalleryVideo,
PickAttachmentFile? pickAttachmentFile,
SanitizeImageBytes? sanitizeImageBytes,
TranscodeImageToJpeg? transcodeImageToJpeg,
TranscodeVideoToMp4? transcodeVideoToMp4,
GenerateVideoPoster? generateVideoPoster,
ReadClipboardImage? readClipboardImage,
DateTime Function()? now,
http.Client? httpClient,
}) : _baseUrl = baseUrl,
_nsec = nsec,
_pickGalleryImage = pickGalleryImage,
_pickGalleryImages =
pickGalleryImages ??
(() async {
final image = await pickGalleryImage();
return image == null ? const <XFile>[] : [image];
}),
_pickGalleryVideo = pickGalleryVideo,
_pickAttachmentFile = pickAttachmentFile,
_sanitizeImageBytes = sanitizeImageBytes ?? _sanitizePickedImageBytes,
_transcodeImageToJpeg =
transcodeImageToJpeg ?? _transcodePickedImageToJpeg,
_transcodeVideoToMp4 = transcodeVideoToMp4 ?? _transcodePickedVideoToMp4,
_generateVideoPoster = generateVideoPoster ?? _generatePickedVideoPoster,
_readClipboardImage = readClipboardImage ?? _readPlatformClipboardImage,
_now = now ?? DateTime.now,
_http = httpClient ?? http.Client(),
_ownsHttpClient = httpClient == null;
void dispose() {
if (_ownsHttpClient) {
_http.close();
}
}
Future<BlobDescriptor?> pickAndUploadImage() async {
final pickedImage = await _pickGalleryImage();
if (pickedImage == null) return null;
return uploadImage(pickedImage);
}
/// Opens the system picker with multi-selection enabled.
Future<List<XFile>> pickGalleryImages() => _pickGalleryImages();
Future<BlobDescriptor> uploadImage(
XFile image, {
ValueChanged<double>? onProgress,
UploadCancellationToken? cancellationToken,
}) async {
final preparedImage = await _prepareUploadImage(image);
_throwIfCancelled(cancellationToken);
return _uploadPreparedBytes(
preparedImage.bytes,
mimeType: preparedImage.mimeType,
onProgress: onProgress,
cancellationToken: cancellationToken,
);
}
Future<bool> clipboardHasImage() async {
return await _mediaUploadPlatformChannel.invokeMethod<bool>(
_clipboardHasImageMethod,
) ??
false;
}
Future<BlobDescriptor> readAndUploadClipboardImage() async {
final image = await readClipboardImage();
if (image == null) throw Exception('Unable to read pasted image');
return uploadImage(image);
}
/// Reads a clipboard image for composer preview before the user sends it.
Future<XFile?> readClipboardImage({String filename = 'Pasted image'}) async {
final bytes = await _readClipboardImage();
if (bytes == null || bytes.isEmpty) return null;
return XFile.fromData(bytes, name: filename);
}
/// Opens the system gallery video picker.
Future<XFile?> pickGalleryVideo() => _pickGalleryVideo();
/// Sanitizes and uploads [pickedVideo] as an MP4 attachment.
Future<BlobDescriptor> uploadVideo(
XFile pickedVideo, {
ValueChanged<double>? onProgress,
UploadCancellationToken? cancellationToken,
}) async {
_throwIfCancelled(cancellationToken);
final length = await pickedVideo.length();
if (length > _maxVideoSizeBytes) {
throw Exception(
'Video is too large (${(length / 1024 / 1024).toStringAsFixed(0)}MB). Maximum is 100MB.',
);
}
// Always rebuild the container. Passing an existing MP4 through would retain
// QuickTime GPS, global metadata, chapters, or non-A/V tracks.
String? transcodedPath;
try {
transcodedPath = await _transcodeVideoToMp4(pickedVideo.path);
_throwIfCancelled(cancellationToken);
final transcodedFile = File(transcodedPath);
final transcodedLength = await transcodedFile.length();
if (transcodedLength > _maxVideoSizeBytes) {
throw Exception(
'Transcoded video is too large (${(transcodedLength / 1024 / 1024).toStringAsFixed(0)}MB). Maximum is 100MB.',
);
}
final bytes = await transcodedFile.readAsBytes();
_throwIfCancelled(cancellationToken);
final video = await uploadBytes(
bytes,
mimeType: 'video/mp4',
onProgress: onProgress == null
? null
: (progress) => onProgress(progress * 0.9),
cancellationToken: cancellationToken,
);
// Extract from the canonical output first so the poster matches the
// uploaded orientation. Some AVFoundation exports need a moment before
// their first frame is seekable; fall back to the picked source instead
// of silently sending a permanently gray video card.
Uint8List? posterBytes;
Object? posterExtractionError;
for (final sourcePath in {transcodedPath, pickedVideo.path}) {
try {
final candidate = await _generateVideoPoster(sourcePath);
if (candidate != null && candidate.isNotEmpty) {
posterBytes = candidate;
break;
}
} catch (error) {
posterExtractionError = error;
}
}
if (posterBytes == null) {
if (posterExtractionError != null) {
debugPrint('Unable to generate video poster: $posterExtractionError');
}
onProgress?.call(1);
return video;
}
// Posters are best-effort: a video that passed the media policy should
// still send if the separate preview upload fails.
try {
_throwIfCancelled(cancellationToken);
final poster = await uploadImage(
XFile.fromData(
posterBytes,
mimeType: 'image/jpeg',
name: 'video-poster.jpg',
),
onProgress: onProgress == null
? null
: (progress) => onProgress(0.9 + (progress * 0.1)),
cancellationToken: cancellationToken,
);
return video.withImage(poster.url);
} catch (error) {
debugPrint('Unable to upload video poster: $error');
onProgress?.call(1);
return video;
}
} finally {
if (transcodedPath != null) {
try {
await File(transcodedPath).delete();
} catch (_) {
// Best-effort temp file cleanup.
}
}
}
}
Future<BlobDescriptor?> pickAndUploadVideo() async {
final pickedVideo = await pickGalleryVideo();
if (pickedVideo == null) return null;
return uploadVideo(pickedVideo);
}
/// Opens the system document picker for a generic file attachment.
Future<XFile?> pickAttachmentFile() async {
final pickAttachmentFile = _pickAttachmentFile;
if (pickAttachmentFile == null) {
throw Exception("File attachments aren't available on this device.");
}
return pickAttachmentFile();
}
/// Uploads [pickedFile] as a size-limited generic attachment.
Future<BlobDescriptor> uploadFile(
XFile pickedFile, {
ValueChanged<double>? onProgress,
UploadCancellationToken? cancellationToken,
}) async {
_throwIfCancelled(cancellationToken);
final length = await pickedFile.length();
if (length == 0) {
throw Exception('File is empty.');
}
if (length > _maxFileSizeBytes) {
throw Exception(
'File is too large (${(length / 1024 / 1024).toStringAsFixed(0)}MB). Maximum is 100MB.',
);
}
final bytes = await pickedFile.readAsBytes();
_throwIfCancelled(cancellationToken);
final descriptor = await _uploadPreparedBytes(
bytes,
mimeType: 'application/octet-stream',
allowGenericFile: true,
onProgress: onProgress,
cancellationToken: cancellationToken,
);
return descriptor.withFilename(_safeAttachmentFilename(pickedFile.name));
}
Future<BlobDescriptor?> pickAndUploadFile() async {
final pickedFile = await pickAttachmentFile();
if (pickedFile == null) return null;
return uploadFile(pickedFile);
}
Future<BlobDescriptor> uploadBytes(
Uint8List bytes, {
required String mimeType,
ValueChanged<double>? onProgress,
UploadCancellationToken? cancellationToken,
}) async {
_throwIfCancelled(cancellationToken);
if (mimeType == 'image/gif' ||
(mimeType == 'image/png' && _isAnimatedPng(bytes)) ||
(mimeType == 'image/webp' && _isAnimatedWebp(bytes))) {
try {
bytes = sanitizeAnimatedImageForUpload(bytes, mimeType);
} on FormatException {
throw Exception('failed to sanitize image for upload');
}
}
return _uploadPreparedBytes(
bytes,
mimeType: mimeType,
onProgress: onProgress,
cancellationToken: cancellationToken,
);
}
Future<BlobDescriptor> _uploadPreparedBytes(
Uint8List bytes, {
required String mimeType,
bool allowGenericFile = false,
ValueChanged<double>? onProgress,
UploadCancellationToken? cancellationToken,
}) async {
_throwIfCancelled(cancellationToken);
if (!allowGenericFile &&
!_allowedImageMimeTypes.contains(mimeType) &&
!_allowedVideoMimeTypes.contains(mimeType)) {
throw Exception('unsupported file type: $mimeType');
}
final sha256 = _sha256Hex(bytes);
var response = await _sendUploadRequest(
bytes: bytes,
mimeType: mimeType,
sha256: sha256,
path: _mediaUploadPath,
onProgress: onProgress,
cancellationToken: cancellationToken,
);
if (response.statusCode == HttpStatus.notFound ||
response.statusCode == HttpStatus.methodNotAllowed) {
response = await _sendUploadRequest(
bytes: bytes,
mimeType: mimeType,
sha256: sha256,
path: _legacyMediaUploadPath,
onProgress: onProgress,
cancellationToken: cancellationToken,
);
}
if (response.statusCode < 200 || response.statusCode >= 300) {
if (_allowedImageMimeTypes.contains(mimeType) &&
(response.statusCode == HttpStatus.unsupportedMediaType ||
response.statusCode == HttpStatus.unprocessableEntity)) {
throw const MediaPolicyUploadException();
}
throw Exception(
'upload failed (${response.statusCode}): ${response.body}',
);
}
return BlobDescriptor.fromJson(
jsonDecode(response.body) as Map<String, dynamic>,
);
}
Future<http.Response> _sendUploadRequest({
required Uint8List bytes,
required String mimeType,
required String sha256,
required String path,
ValueChanged<double>? onProgress,
UploadCancellationToken? cancellationToken,
}) async {
_throwIfCancelled(cancellationToken);
final request = http.AbortableStreamedRequest(
'PUT',
Uri.parse(_baseUrl).resolve(path),
abortTrigger: cancellationToken?.whenCancelled,
);
request.contentLength = bytes.length;
request.headers.addAll(
_buildUploadHeaders(mimeType: mimeType, sha256: sha256),
);
final writeRequest = request.sink
.addStream(_uploadByteStream(bytes, onProgress))
.whenComplete(request.sink.close);
final response = await _http.send(request);
await writeRequest;
_throwIfCancelled(cancellationToken);
return http.Response.fromStream(response);
}
void _throwIfCancelled(UploadCancellationToken? cancellationToken) {
if (cancellationToken?.isCancelled ?? false) {
throw const UploadCancelledException();
}
}
Map<String, String> _buildUploadHeaders({
required String mimeType,
required String sha256,
}) {
final headers = <String, String>{
'Authorization': _buildUploadAuthHeader(sha256),
'Content-Type': mimeType,
'X-SHA-256': sha256,
};
return headers;
}
String _buildUploadAuthHeader(String sha256) {
final authEvent = _buildUploadAuthEvent(sha256);
final authJson = authEvent.toJson();
final encoded = base64Url.encode(utf8.encode(authJson)).replaceAll('=', '');
return 'Nostr $encoded';
}
nostr.Event _buildUploadAuthEvent(String sha256) {
final nsec = _nsec;
if (nsec == null || nsec.isEmpty) {
throw Exception('Cannot upload media: no signing key available');
}
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
if (privkeyHex.isEmpty) {
throw Exception('Invalid nsec');
}
final expiration =
(_now().millisecondsSinceEpoch ~/ 1000) + _uploadAuthLifetimeSeconds;
final tags = <List<String>>[
['t', 'upload'],
['x', sha256],
['expiration', '$expiration'],
if (extractServerAuthority(_baseUrl) case final authority?)
['server', authority],
];
return nostr.Event.from(
kind: _uploadAuthKind,
content: 'Upload buzz-media',
tags: tags,
secretKey: privkeyHex,
verify: false,
);
}
Future<_PreparedUploadImage> _prepareUploadImage(XFile pickedImage) async {
final bytes = await pickedImage.readAsBytes();
final detectedMimeType = _tryDetectImageMimeType(bytes);
if (detectedMimeType case final mimeType?) {
return _prepareDetectedUploadImage(bytes, mimeType);
}
if (_shouldTranscodePickedImage(pickedImage, bytes)) {
return _prepareTranscodedUploadImage(bytes);
}
throw Exception('unsupported file type');
}
Future<_PreparedUploadImage> _prepareDetectedUploadImage(
Uint8List bytes,
String mimeType,
) async {
final preparedBytes = await _sanitizeImageBytesIfNeeded(bytes, mimeType);
return _buildPreparedUploadImage(preparedBytes);
}
Future<_PreparedUploadImage> _prepareTranscodedUploadImage(
Uint8List bytes,
) async {
final transcodedBytes = await _transcodeImageToJpeg(bytes);
return _buildPreparedUploadImage(transcodedBytes);
}
_PreparedUploadImage _buildPreparedUploadImage(Uint8List bytes) {
return _PreparedUploadImage(
bytes: bytes,
mimeType: _detectImageMimeType(bytes),
);
}
Future<Uint8List> _sanitizeImageBytesIfNeeded(
Uint8List bytes,
String mimeType,
) async {
if (mimeType == 'image/gif' ||
(mimeType == 'image/png' && _isAnimatedPng(bytes)) ||
(mimeType == 'image/webp' && _isAnimatedWebp(bytes))) {
try {
return sanitizeAnimatedImageForUpload(bytes, mimeType);
} on FormatException {
throw Exception('failed to sanitize image for upload');
}
}
if (!_shouldSanitizePickedImage(mimeType)) {
return bytes;
}
final sanitizedBytes = await _sanitizeImageBytes(bytes, mimeType);
if (sanitizedBytes.isEmpty) {
throw Exception('failed to sanitize image for upload');
}
return sanitizedBytes;
}
}
String _safeAttachmentFilename(String filename) {
final segments = filename.split(RegExp(r'[/\\]'));
final basename = segments.isEmpty ? '' : segments.last;
final sanitized = StringBuffer();
var byteLength = 0;
for (final rune in basename.runes) {
if ((rune >= 0 && rune <= 0x1f) || (rune >= 0x7f && rune <= 0x9f)) {
continue;
}
final character = String.fromCharCode(rune);
final characterByteLength = utf8.encode(character).length;
if (byteLength + characterByteLength > 255) break;
sanitized.write(character);
byteLength += characterByteLength;
}
final safeBasename = sanitized.toString().trim();
return safeBasename.isEmpty ? 'file' : safeBasename;
}
Stream<List<int>> _uploadByteStream(
Uint8List bytes,
ValueChanged<double>? onProgress,
) async* {
const chunkSize = 64 * 1024;
onProgress?.call(0);
if (bytes.isEmpty) {
onProgress?.call(1);
return;
}
for (var start = 0; start < bytes.length; start += chunkSize) {
final end = math.min(start + chunkSize, bytes.length);
yield Uint8List.sublistView(bytes, start, end);
onProgress?.call(end / bytes.length);
}
}
String _sha256Hex(Uint8List bytes) {
final digest = SHA256Digest().process(bytes);
return digest.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
}
String? _tryDetectImageMimeType(Uint8List bytes) {
try {
return _detectImageMimeType(bytes);
} on Exception {
return null;
}
}
String _detectImageMimeType(Uint8List bytes) {
if (_startsWith(bytes, const [0xff, 0xd8, 0xff])) {
return 'image/jpeg';
}
if (_startsWith(bytes, const [
0x89,
0x50,
0x4e,
0x47,
0x0d,
0x0a,
0x1a,
0x0a,
])) {
return 'image/png';
}
if (_startsWith(bytes, ascii.encode('GIF87a')) ||
_startsWith(bytes, ascii.encode('GIF89a'))) {
return 'image/gif';
}
if (_startsWith(bytes, ascii.encode('RIFF')) &&
bytes.length >= 12 &&
ascii.decode(bytes.sublist(8, 12), allowInvalid: true) == 'WEBP') {
return 'image/webp';
}
throw Exception('unsupported file type');
}
bool _shouldTranscodePickedImage(XFile pickedImage, Uint8List bytes) {
return _supportsNativeUploadImageProcessing() &&
(_hasHeicFileExtension(pickedImage) || _looksLikeHeicOrHeif(bytes));
}
bool _isAnimatedPng(Uint8List bytes) {
if (!_startsWith(bytes, const [
0x89,
0x50,
0x4e,
0x47,
0x0d,
0x0a,
0x1a,
0x0a,
])) {
return false;
}
var offset = 8;
while (offset + 12 <= bytes.length) {
final chunkSize = _readUint32BigEndian(bytes, offset);
if (offset + 12 + chunkSize > bytes.length) {
return false;
}
if (_matchesAscii(bytes, offset + 4, 'acTL')) {
return true;
}
offset += 12 + chunkSize;
}
return false;
}
bool _isAnimatedWebp(Uint8List bytes) {
if (!_startsWith(bytes, ascii.encode('RIFF')) ||
bytes.length < 12 ||
ascii.decode(bytes.sublist(8, 12), allowInvalid: true) != 'WEBP') {
return false;
}
var offset = 12;
while (offset + 8 <= bytes.length) {
final chunkSize = _readUint32LittleEndian(bytes, offset + 4);
final payloadOffset = offset + 8;
if (payloadOffset + chunkSize > bytes.length) {
return false;
}
if (_matchesAscii(bytes, offset, 'ANIM') ||
_matchesAscii(bytes, offset, 'ANMF')) {
return true;
}
if (_matchesAscii(bytes, offset, 'VP8X') &&
chunkSize >= 1 &&
(bytes[payloadOffset] & 0x02) != 0) {
return true;
}
offset = payloadOffset + chunkSize + (chunkSize.isOdd ? 1 : 0);
}
return false;
}
bool _shouldSanitizePickedImage(String mimeType) {
return _supportsNativeUploadImageProcessing() &&
(mimeType == 'image/jpeg' ||
mimeType == 'image/png' ||
mimeType == 'image/webp');
}
bool _supportsNativeUploadImageProcessing() {
return switch (defaultTargetPlatform) {
TargetPlatform.android || TargetPlatform.iOS => true,
_ => false,
};
}
bool _hasHeicFileExtension(XFile pickedImage) {
for (final candidate in [pickedImage.name, pickedImage.path]) {
final normalizedCandidate = candidate.toLowerCase();
if (normalizedCandidate.endsWith('.heic') ||
normalizedCandidate.endsWith('.heif')) {
return true;
}
}
return false;
}
bool _looksLikeHeicOrHeif(Uint8List bytes) {
if (bytes.length < 12 || !_matchesAscii(bytes, 4, 'ftyp')) {
return false;
}
final upperBound = bytes.length < 32 ? bytes.length : 32;
for (var offset = 8; offset + 4 <= upperBound; offset += 4) {
final brand = ascii.decode(
bytes.sublist(offset, offset + 4),
allowInvalid: true,
);
if (_heicBrands.contains(brand.toLowerCase())) {
return true;
}
}
return false;
}
bool _startsWith(Uint8List bytes, List<int> prefix) {
if (bytes.length < prefix.length) return false;
for (var i = 0; i < prefix.length; i++) {
if (bytes[i] != prefix[i]) return false;
}
return true;
}
bool _matchesAscii(Uint8List bytes, int offset, String value) {
final codeUnits = ascii.encode(value);
if (bytes.length < offset + codeUnits.length) return false;
for (var i = 0; i < codeUnits.length; i++) {
if (bytes[offset + i] != codeUnits[i]) return false;
}
return true;
}
int _readUint32BigEndian(Uint8List bytes, int offset) {
return (bytes[offset] << 24) |
(bytes[offset + 1] << 16) |
(bytes[offset + 2] << 8) |
bytes[offset + 3];
}
int _readUint32LittleEndian(Uint8List bytes, int offset) {
return bytes[offset] |
(bytes[offset + 1] << 8) |
(bytes[offset + 2] << 16) |
(bytes[offset + 3] << 24);
}
Future<Uint8List?> _readPlatformClipboardImage() async {
return _mediaUploadPlatformChannel.invokeMethod<Uint8List>(
_readClipboardImageMethod,
);
}
Future<Uint8List?> _generatePickedVideoPoster(String filePath) {
return _mediaUploadPlatformChannel.invokeMethod<Uint8List>(
_generateVideoPosterMethod,
filePath,
);
}
Future<String> _transcodePickedVideoToMp4(String filePath) async {
final result = await _mediaUploadPlatformChannel.invokeMethod<String>(
_transcodeVideoToMp4Method,
filePath,
);
if (result == null || result.isEmpty) {
throw Exception('Failed to convert video to MP4.');
}
if (defaultTargetPlatform == TargetPlatform.android) {
final source = File(result);
final destination = File(
'$result.faststart-${DateTime.now().microsecondsSinceEpoch}.mp4',
);
try {
await rewriteMp4ForFastStart(source, destination);
await source.delete();
return destination.path;
} catch (_) {
try {
await destination.delete();
} on FileSystemException {
// Best-effort cleanup; preserve the original platform error.
}
rethrow;
}
}
return result;
}
Future<Uint8List> _transcodePickedImageToJpeg(Uint8List bytes) async {
return _invokeRequiredPlatformBytesMethod(
_transcodeImageToJpegMethod,
arguments: bytes,
errorMessage: 'failed to convert image for upload',
);
}
Future<Uint8List> _sanitizePickedImageBytes(
Uint8List bytes,
String mimeType,
) async {
return _invokeRequiredPlatformBytesMethod(
_sanitizeImageForUploadMethod,
arguments: {'bytes': bytes, 'mimeType': mimeType},
errorMessage: 'failed to sanitize image for upload',
);
}
Future<Uint8List> _invokeRequiredPlatformBytesMethod(
String method, {
Object? arguments,
required String errorMessage,
}) async {
final result = await _mediaUploadPlatformChannel.invokeMethod<Uint8List>(
method,
arguments,
);
if (result == null || result.isEmpty) {
throw Exception(errorMessage);
}
return result;
}
final mediaUploadServiceProvider = Provider<MediaUploadService>((ref) {
final config = ref.watch(relayConfigProvider);
final picker = ImagePicker();
final service = MediaUploadService(
baseUrl: config.baseUrl,
nsec: config.nsec,
pickGalleryImage: () => picker.pickImage(
source: ImageSource.gallery,
requestFullMetadata: false,
),
pickGalleryImages: () => picker.pickMultiImage(requestFullMetadata: false),
pickGalleryVideo: () => picker.pickVideo(source: ImageSource.gallery),
pickAttachmentFile: file_selector.openFile,
);
ref.onDispose(service.dispose);
return service;
});
+345
View File
@@ -0,0 +1,345 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
const _maxTopLevelBoxes = 4096;
const _maxNestedBoxes = 100000;
const _maxBoxDepth = 32;
const _maxMoovBytes = 64 * 1024 * 1024;
const _copyBufferBytes = 1024 * 1024;
const _uint32Max = 0xffffffff;
const _uint64Max = 0x7fffffffffffffff;
const _containerTypes = {
'moov',
'trak',
'mdia',
'minf',
'stbl',
'edts',
'dinf',
};
final class _Mp4Box {
final int offset;
final int size;
final int headerSize;
final String type;
const _Mp4Box({
required this.offset,
required this.size,
required this.headerSize,
required this.type,
});
}
/// Rewrites [source] to [destination] with `moov` before the first `mdat`.
///
/// Only chunk offsets into the region moved by the relocation are adjusted.
/// The files must be distinct, and malformed or excessively nested inputs fail
/// closed rather than producing a partially valid upload.
Future<void> rewriteMp4ForFastStart(File source, File destination) async {
if (source.absolute.path == destination.absolute.path) {
throw const FormatException(
'MP4 fast-start rewrite requires a distinct destination',
);
}
final input = await source.open();
try {
final boxes = await _readTopLevelBoxes(input);
if (boxes.isEmpty || boxes.first.type != 'ftyp') {
throw const FormatException('MP4 must start with an ftyp box');
}
final moovBoxes = boxes.where((box) => box.type == 'moov').toList();
if (moovBoxes.length != 1) {
throw const FormatException('MP4 must contain exactly one moov box');
}
final moov = moovBoxes.single;
final mdatBoxes = boxes.where((box) => box.type == 'mdat').toList();
final firstMdat = mdatBoxes.isEmpty ? null : mdatBoxes.first;
if (firstMdat == null) {
throw const FormatException('MP4 is missing an mdat box');
}
if (moov.size > _maxMoovBytes) {
throw const FormatException('MP4 moov box is too large');
}
await input.setPosition(moov.offset);
final moovBytes = await input.read(moov.size);
if (moovBytes.length != moov.size) {
throw const FormatException('truncated MP4 moov box');
}
if (moov.offset > firstMdat.offset) {
_patchChunkOffsets(
moovBytes,
moov.headerSize,
moovBytes.length,
firstMdat.offset,
moov.offset,
moov.size,
0,
[0],
);
}
final output = await destination.open(mode: FileMode.write);
try {
var insertedMoov = false;
for (final box in boxes) {
if (box.type == 'moov') continue;
if (!insertedMoov && box.type == 'mdat') {
await output.writeFrom(moovBytes);
insertedMoov = true;
}
await _copyRange(input, output, box.offset, box.size);
}
if (!insertedMoov) {
throw const FormatException('MP4 is missing an mdat box');
}
await output.flush();
} finally {
await output.close();
}
} catch (_) {
try {
await destination.delete();
} on FileSystemException {
// Best-effort cleanup of a partial rewrite.
}
rethrow;
} finally {
await input.close();
}
}
Future<List<_Mp4Box>> _readTopLevelBoxes(RandomAccessFile input) async {
final length = await input.length();
final boxes = <_Mp4Box>[];
var offset = 0;
while (offset < length) {
if (boxes.length >= _maxTopLevelBoxes) {
throw const FormatException('MP4 has too many top-level boxes');
}
final box = await _readFileBoxHeader(input, offset, length);
boxes.add(box);
offset += box.size;
}
if (offset != length) {
throw const FormatException('MP4 boxes do not cover the file');
}
return boxes;
}
Future<_Mp4Box> _readFileBoxHeader(
RandomAccessFile input,
int offset,
int end,
) async {
if (end - offset < 8) {
throw const FormatException('truncated MP4 box header');
}
await input.setPosition(offset);
final compactHeader = await input.read(8);
final compactSize = _readUint32(compactHeader, 0);
final type = latin1.decode(compactHeader.sublist(4, 8));
final int headerSize;
final int size;
if (compactSize == 1) {
if (end - offset < 16) {
throw const FormatException('truncated extended MP4 box header');
}
final extended = await input.read(8);
headerSize = 16;
size = _readUint64(extended, 0);
} else if (compactSize == 0) {
headerSize = 8;
size = end - offset;
} else {
headerSize = 8;
size = compactSize;
}
if (size < headerSize || size > end - offset) {
throw const FormatException('invalid MP4 box size');
}
return _Mp4Box(
offset: offset,
size: size,
headerSize: headerSize,
type: type,
);
}
Future<void> _copyRange(
RandomAccessFile input,
RandomAccessFile output,
int offset,
int size,
) async {
await input.setPosition(offset);
var remaining = size;
while (remaining > 0) {
final bytes = await input.read(
remaining < _copyBufferBytes ? remaining : _copyBufferBytes,
);
if (bytes.isEmpty) {
throw const FormatException('truncated MP4 while copying');
}
await output.writeFrom(bytes);
remaining -= bytes.length;
}
}
void _patchChunkOffsets(
Uint8List bytes,
int start,
int end,
int movedRegionStart,
int movedRegionEnd,
int delta,
int depth,
List<int> boxesSeen,
) {
if (depth > _maxBoxDepth) {
throw const FormatException('MP4 box nesting is too deep');
}
var offset = start;
while (offset < end) {
boxesSeen[0]++;
if (boxesSeen[0] > _maxNestedBoxes) {
throw const FormatException('MP4 has too many nested boxes');
}
final box = _readMemoryBoxHeader(bytes, offset, end);
final boxEnd = offset + box.size;
switch (box.type) {
case 'stco':
_patchStco(
bytes,
offset + box.headerSize,
boxEnd,
movedRegionStart,
movedRegionEnd,
delta,
);
break;
case 'co64':
_patchCo64(
bytes,
offset + box.headerSize,
boxEnd,
movedRegionStart,
movedRegionEnd,
delta,
);
break;
case final type when _containerTypes.contains(type):
_patchChunkOffsets(
bytes,
offset + box.headerSize,
boxEnd,
movedRegionStart,
movedRegionEnd,
delta,
depth + 1,
boxesSeen,
);
break;
}
offset = boxEnd;
}
if (offset != end) {
throw const FormatException('MP4 child boxes do not cover their parent');
}
}
_Mp4Box _readMemoryBoxHeader(Uint8List bytes, int offset, int end) {
if (end - offset < 8) {
throw const FormatException('truncated MP4 child box');
}
final compactSize = _readUint32(bytes, offset);
final type = latin1.decode(bytes.sublist(offset + 4, offset + 8));
final int headerSize;
final int size;
if (compactSize == 1) {
if (end - offset < 16) {
throw const FormatException('truncated extended MP4 child box');
}
headerSize = 16;
size = _readUint64(bytes, offset + 8);
} else if (compactSize == 0) {
headerSize = 8;
size = end - offset;
} else {
headerSize = 8;
size = compactSize;
}
if (size < headerSize || size > end - offset) {
throw const FormatException('invalid MP4 child box size');
}
return _Mp4Box(
offset: offset,
size: size,
headerSize: headerSize,
type: type,
);
}
void _patchStco(
Uint8List bytes,
int start,
int end,
int movedRegionStart,
int movedRegionEnd,
int delta,
) {
if (end - start < 8) throw const FormatException('truncated stco box');
final count = _readUint32(bytes, start + 4);
if (count > (end - start - 8) ~/ 4) {
throw const FormatException('invalid stco entry count');
}
final data = ByteData.sublistView(bytes);
for (var index = 0; index < count; index++) {
final entry = start + 8 + index * 4;
final value = data.getUint32(entry, Endian.big);
if (value >= movedRegionStart && value < movedRegionEnd) {
final adjusted = value + delta;
if (adjusted > _uint32Max) {
throw const FormatException('stco offset overflow');
}
data.setUint32(entry, adjusted, Endian.big);
}
}
}
void _patchCo64(
Uint8List bytes,
int start,
int end,
int movedRegionStart,
int movedRegionEnd,
int delta,
) {
if (end - start < 8) throw const FormatException('truncated co64 box');
final count = _readUint32(bytes, start + 4);
if (count > (end - start - 8) ~/ 8) {
throw const FormatException('invalid co64 entry count');
}
final data = ByteData.sublistView(bytes);
for (var index = 0; index < count; index++) {
final entry = start + 8 + index * 8;
final value = data.getUint64(entry, Endian.big);
if (value >= movedRegionStart && value < movedRegionEnd) {
final adjusted = value + delta;
if (adjusted > _uint64Max) {
throw const FormatException('co64 offset overflow');
}
data.setUint64(entry, adjusted, Endian.big);
}
}
}
int _readUint32(Uint8List bytes, int offset) =>
ByteData.sublistView(bytes).getUint32(offset, Endian.big);
int _readUint64(Uint8List bytes, int offset) =>
ByteData.sublistView(bytes).getUint64(offset, Endian.big);
+215
View File
@@ -0,0 +1,215 @@
import 'nostr_models.dart';
/// Canonical [NostrFilter] constructors for common Buzz queries.
///
/// Centralising filter shapes keeps relay queries consistent across providers
/// and makes kind/tag conventions easy to audit.
abstract final class NostrFilters {
/// Channels where I'm a member (kind:39002 with `#p` = my pubkey).
static NostrFilter myChannels(String myPk) => NostrFilter(
kinds: [39002],
tags: {
'#p': [myPk],
},
limit: 500,
);
/// Channel metadata for the given channel IDs.
static NostrFilter channelMetadata(List<String> ids) =>
NostrFilter(kinds: [39000], tags: {'#d': ids}, limit: ids.length);
/// Members list for a single channel.
static NostrFilter channelMembers(String channelId) => NostrFilter(
kinds: [39002],
tags: {
'#d': [channelId],
},
limit: 1,
);
/// A single user's profile (kind:0).
static NostrFilter profile(String pubkey) =>
NostrFilter(kinds: [0], authors: [pubkey], limit: 1);
/// Batch user profiles (kind:0) for multiple pubkeys.
static NostrFilter profilesBatch(List<String> pubkeys) =>
NostrFilter(kinds: [0], authors: pubkeys, limit: pubkeys.length);
/// Channel messages (all event kinds that appear in channels).
static NostrFilter messages(
String channelId, {
int limit = 200,
int? until,
}) => NostrFilter(
kinds: EventKind.channelEventKinds,
tags: {
'#h': [channelId],
},
limit: limit,
until: until,
);
/// Reactions (kind:7) on a specific event.
static NostrFilter reactions(String eventId) => NostrFilter(
kinds: [7],
tags: {
'#e': [eventId],
},
);
/// Canvas event for a channel.
static NostrFilter canvas(String channelId) => NostrFilter(
kinds: [40100],
tags: {
'#h': [channelId],
},
limit: 1,
);
/// Workflows (kind:30620) in a channel.
static NostrFilter workflows(String channelId) => NostrFilter(
kinds: [30620],
tags: {
'#h': [channelId],
},
);
/// DM channels where I'm a participant.
static NostrFilter dmList(String myPk) => NostrFilter(
kinds: [39000],
tags: {
'#t': ['dm'],
'#p': [myPk],
},
);
/// Latest per-viewer hidden-DM snapshot (kind:30622, `#p` = my pubkey).
static NostrFilter hiddenDms(String myPk) => NostrFilter(
kinds: [EventKind.dmVisibility],
tags: {
'#p': [myPk],
},
limit: 1,
);
/// Forum posts (kind:45001) in a channel.
static NostrFilter forumPosts(
String channelId, {
int limit = 50,
int? until,
}) => NostrFilter(
kinds: [45001],
tags: {
'#h': [channelId],
},
limit: limit,
until: until,
);
/// Replies in a forum thread (root event id + channel scope).
static NostrFilter forumThread(String rootId, String channelId) =>
NostrFilter(
kinds: [9, 45003],
tags: {
'#e': [rootId],
'#h': [channelId],
},
);
/// NIP-50 message search, optionally scoped to a channel.
static NostrFilter searchMessages(
String query, {
String? channelId,
int limit = 20,
}) => NostrFilter(
kinds: [9, 40002, 45001, 45003],
tags: channelId != null
? {
'#h': [channelId],
}
: const {},
search: query,
limit: limit,
);
/// Global user search over kind:0 profiles (NIP-50 via the HTTP bridge).
///
/// `search_mode: "prefix"` is a Buzz bridge-only extension: every caller is
/// a typeahead surface, so a partially typed name must match ("rac" →
/// "raccoon"). Mirrors desktop's `build_user_search_filter`
/// (desktop/src-tauri/src/commands/profile.rs). Bridge-only — send through
/// `queryRelay`, not a WebSocket REQ.
static NostrFilter searchUsers(String query, {int limit = 50}) => NostrFilter(
kinds: [0],
search: query,
limit: limit,
extensions: const {'search_mode': 'prefix'},
);
/// Deletions (kind:5) targeting event IDs.
static NostrFilter deletionsByTargetIds(
List<String> ids, {
List<String>? authors,
}) => NostrFilter(
kinds: [EventKind.deletion],
authors: authors,
tags: {'#e': ids},
limit: ids.length,
);
/// User notes (kind:1) for the global Pulse timeline.
static NostrFilter globalNotes({int limit = 50, int? until}) =>
NostrFilter(kinds: [EventKind.note], limit: limit, until: until);
/// Notes by a set of authors for Pulse timelines.
static NostrFilter notesTimeline(
List<String> pubkeys, {
int limit = 200,
int? until,
}) => NostrFilter(
kinds: [EventKind.note],
authors: pubkeys,
limit: limit,
until: until,
);
/// Reactions authored by a user.
static NostrFilter userReactions(String pubkey, {int limit = 200}) =>
NostrFilter(kinds: [EventKind.reaction], authors: [pubkey], limit: limit);
/// Reactions targeting notes.
static NostrFilter noteReactions(List<String> noteIds) => NostrFilter(
kinds: [EventKind.reaction],
tags: {'#e': noteIds},
limit: 500,
);
/// Fetch notes by ids.
static NostrFilter notesByIds(List<String> ids) =>
NostrFilter(kinds: [EventKind.note], ids: ids, limit: ids.length);
/// User notes (kind:1) for a single author.
static NostrFilter userNotes(String pubkey, {int limit = 20, int? until}) =>
NostrFilter(
kinds: [EventKind.note],
authors: [pubkey],
limit: limit,
until: until,
);
/// Contact list (kind:3) for a user.
static NostrFilter contactList(String pubkey) =>
NostrFilter(kinds: [EventKind.contactList], authors: [pubkey], limit: 1);
/// Relay membership list (kind:13534).
static NostrFilter relayMembers() =>
const NostrFilter(kinds: [13534], limit: 1);
/// Agent profiles (kind:10100).
static NostrFilter agentProfiles() =>
const NostrFilter(kinds: [10100], limit: 100);
/// User status (NIP-38, kind:30315).
static NostrFilter userStatus(String pubkey) =>
NostrFilter(kinds: [30315], authors: [pubkey], limit: 1);
}
+407
View File
@@ -0,0 +1,407 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
/// Nostr event kind constants.
///
/// Keep in sync with `desktop/src/shared/constants/kinds.ts`.
abstract final class EventKind {
static const note = 1;
static const contactList = 3;
static const deletion = 5;
static const reaction = 7;
static const streamMessage = 9;
static const nip29DeleteEvent = 9005;
static const presenceUpdate = 20001;
static const typingIndicator = 20002;
static const auth = 22242;
static const agentObserverFrame = 24200;
static const huddleReaction = 24810;
static const readState = 30078;
static const eventReminder = 30300;
static const userStatus = 30315;
static const dmVisibility = 30622;
static const streamMessageV2 = 40002;
static const channelThreadSummary = 39005;
static const channelWindowBounds = 39006;
static const streamMessageEdit = 40003;
static const streamMessageDiff = 40008;
static const systemMessage = 40099;
static const jobRequest = 43001;
static const jobAccepted = 43002;
static const jobProgress = 43003;
static const jobResult = 43004;
static const jobCancel = 43005;
static const jobError = 43006;
static const forumPost = 45001;
static const forumComment = 45003;
static const huddleStarted = 48100;
static const huddleParticipantJoined = 48101;
static const huddleParticipantLeft = 48102;
static const huddleEnded = 48103;
/// Event kinds that represent user-visible channel messages.
static const channelMessageEventKinds = [
streamMessage, // 9
streamMessageV2, // 40002
forumPost, // 45001
forumComment, // 45003
];
/// Event kinds that represent channel activity (messages, edits, reactions,
/// deletions, system events). Matches the desktop's `CHANNEL_EVENT_KINDS`.
static const channelEventKinds = [
deletion, // 5
reaction, // 7
nip29DeleteEvent, // 9005 — Buzz-native deletion
...channelMessageEventKinds,
40001, // legacy pre-migration stream messages
streamMessageEdit, // 40003
streamMessageDiff, // 40008
systemMessage, // 40099
huddleStarted, // 48100 — visible huddle session row
huddleParticipantJoined, // 48101 — huddle lifecycle metadata
huddleParticipantLeft, // 48102 — huddle lifecycle metadata
huddleEnded, // 48103 — visible huddle ended row
];
/// Auxiliary timeline kinds that overlay or hide existing rows.
static const channelAuxEventKinds = [
deletion,
reaction,
nip29DeleteEvent,
streamMessageEdit,
];
/// Visible content kinds requested by the NIP-CW channel-window path.
static const channelTimelineContentKinds = [
streamMessage,
streamMessageV2,
streamMessageDiff,
systemMessage,
jobRequest,
jobAccepted,
jobProgress,
jobResult,
jobCancel,
jobError,
huddleStarted,
];
}
/// A Nostr event as defined by NIP-01.
@immutable
class NostrEvent {
final String id;
final String pubkey;
final int createdAt;
final int kind;
final List<List<String>> tags;
final String content;
final String sig;
const NostrEvent({
required this.id,
required this.pubkey,
required this.createdAt,
required this.kind,
required this.tags,
required this.content,
required this.sig,
});
factory NostrEvent.fromJson(Map<String, dynamic> json) {
return NostrEvent(
id: json['id'] as String,
pubkey: json['pubkey'] as String,
createdAt: json['created_at'] as int,
kind: json['kind'] as int,
tags: (json['tags'] as List<dynamic>)
.map((t) => (t as List<dynamic>).map((e) => e as String).toList())
.toList(),
content: json['content'] as String,
sig: json['sig'] as String,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'pubkey': pubkey,
'created_at': createdAt,
'kind': kind,
'tags': tags,
'content': content,
'sig': sig,
};
/// Get the first value for a given tag key.
String? getTagValue(String key) {
for (final tag in tags) {
if (tag.isNotEmpty && tag[0] == key && tag.length > 1) {
return tag[1];
}
}
return null;
}
/// The channel/group ID from the `h` tag (NIP-29).
String? get channelId => getTagValue('h');
/// Extract thread parent and root IDs from `e` tags.
///
/// Matches the desktop's `getThreadReference` logic:
/// - Tags with marker `"reply"` identify the direct parent.
/// - Tags with marker `"root"` identify the thread root.
/// - If no markers are present, falls back to null (top-level message).
({String? parentId, String? rootId}) get threadReference {
final eTags = [
for (final tag in tags)
if (tag.length >= 2 && tag[0] == 'e') tag,
];
if (eTags.isEmpty) return (parentId: null, rootId: null);
// Find tagged root and reply markers (desktop convention).
List<String>? rootTag;
List<String>? replyTag;
for (final tag in eTags) {
if (tag.length >= 4) {
if (tag[3] == 'root') rootTag = tag;
if (tag[3] == 'reply') replyTag = tag;
}
}
if (replyTag == null) return (parentId: null, rootId: null);
final parentId = replyTag[1];
final rootId = rootTag?[1] ?? parentId;
return (parentId: parentId, rootId: rootId);
}
/// The parent event ID from the `e` tag.
String? get parentEventId => threadReference.parentId;
@override
bool operator ==(Object other) =>
identical(this, other) || other is NostrEvent && id == other.id;
@override
int get hashCode => id.hashCode;
}
/// A NIP-01 subscription filter.
@immutable
class NostrFilter {
final List<int> kinds;
final List<String>? authors;
/// Specific event IDs (NIP-01 single-event lookup).
final List<String>? ids;
final int limit;
final int? since;
final int? until;
/// NIP-50 full-text search query.
final String? search;
/// Tag filters, e.g. `{'#h': ['channel-id']}`.
final Map<String, List<String>> tags;
/// Buzz relay bridge filter extensions (for example NIP-CW `top_level`).
final Map<String, Object?> extensions;
const NostrFilter({
required this.kinds,
this.authors,
this.ids,
this.limit = 100,
this.since,
this.until,
this.search,
this.tags = const {},
this.extensions = const {},
});
/// Return a copy with an updated `since` value.
NostrFilter copyWithSince(int since) => NostrFilter(
kinds: kinds,
authors: authors,
ids: ids,
limit: limit,
since: since,
until: until,
search: search,
tags: tags,
extensions: extensions,
);
Map<String, dynamic> toJson() {
final json = <String, dynamic>{'kinds': kinds, 'limit': limit};
if (authors != null) json['authors'] = authors;
if (ids != null) json['ids'] = ids;
if (since != null) json['since'] = since;
if (until != null) json['until'] = until;
if (search != null) json['search'] = search;
for (final entry in tags.entries) {
json[entry.key] = entry.value;
}
for (final entry in extensions.entries) {
json[entry.key] = entry.value;
}
return json;
}
}
/// Parsed kind:0 user profile metadata.
@immutable
class ProfileData {
final String pubkey;
final String? displayName;
final String? avatarUrl;
final String? about;
final String? nip05;
const ProfileData({
required this.pubkey,
this.displayName,
this.avatarUrl,
this.about,
this.nip05,
});
factory ProfileData.fromEvent(NostrEvent event) {
Map<String, dynamic> meta = {};
try {
final decoded = jsonDecode(event.content);
if (decoded is Map<String, dynamic>) meta = decoded;
} catch (_) {}
return ProfileData(
pubkey: event.pubkey,
displayName:
(meta['display_name'] as String?) ?? (meta['name'] as String?),
avatarUrl: meta['picture'] as String?,
about: meta['about'] as String?,
nip05: meta['nip05'] as String?,
);
}
}
/// Parsed kind:39000 channel metadata.
@immutable
class ChannelData {
final String id;
final String name;
final String channelType;
final String visibility;
final String description;
final String? topic;
final List<String> participantPubkeys;
final int? ttlSeconds;
final DateTime? ttlDeadline;
final bool isArchived;
const ChannelData({
required this.id,
required this.name,
required this.channelType,
required this.visibility,
required this.description,
this.topic,
this.participantPubkeys = const [],
this.ttlSeconds,
this.ttlDeadline,
this.isArchived = false,
});
factory ChannelData.fromEvent(NostrEvent event) {
final id = event.getTagValue('d') ?? '';
final name = event.getTagValue('name') ?? '';
// Prefer explicit ["t", type]; fall back to ["hidden"] => dm, else "stream".
// The fallback exists for relays that haven't been upgraded to emit the
// explicit type tag yet.
final explicitType = event.getTagValue('t');
final hasHidden = event.tags.any((t) => t.isNotEmpty && t[0] == 'hidden');
final channelType = explicitType ?? (hasHidden ? 'dm' : 'stream');
// Prefer explicit ["public"]; fall back to NIP-29 absence-of-"private".
final hasPublic = event.tags.any((t) => t.isNotEmpty && t[0] == 'public');
final hasPrivate = event.tags.any((t) => t.isNotEmpty && t[0] == 'private');
final visibility = hasPublic
? 'open'
: hasPrivate
? 'private'
: 'open';
final description = event.getTagValue('about') ?? '';
final topic = event.getTagValue('topic');
final participants = [
for (final t in event.tags)
if (t.length >= 2 && t[0] == 'p') t[1],
];
final ttlRaw = event.getTagValue('ttl');
final ttlSeconds = ttlRaw != null ? int.tryParse(ttlRaw) : null;
final ttlDeadlineRaw = event.getTagValue('ttl_deadline');
final ttlDeadline = ttlDeadlineRaw != null
? DateTime.tryParse(ttlDeadlineRaw)
: null;
// Relay republishes kind:39000 with `["archived", "true"]` when a channel
// is archived (including the auto-archive emitted by the TTL reaper). The
// tag value "false" is also accepted server-side, so only treat "true" as
// archived — anything else (missing tag, "false", unexpected value) means
// active.
final isArchived = event.getTagValue('archived') == 'true';
return ChannelData(
id: id,
name: name,
channelType: channelType,
visibility: visibility,
description: description,
topic: topic,
participantPubkeys: participants,
ttlSeconds: ttlSeconds,
ttlDeadline: ttlDeadline,
isArchived: isArchived,
);
}
}
/// A single member entry parsed from a kind:39002 members event.
@immutable
class MemberEntry {
final String pubkey;
final String role;
const MemberEntry({required this.pubkey, required this.role});
}
/// Parse a kind:39002 members event into the list of `(pubkey, role)` entries.
///
/// NIP-29 members tags follow the shape `["p", <pubkey>, <relay>, <role>]`.
List<MemberEntry> membersFromEvent(NostrEvent event) {
return [
for (final t in event.tags)
if (t.length >= 2 && t[0] == 'p')
MemberEntry(pubkey: t[1], role: t.length >= 4 ? t[3] : 'member'),
];
}
/// Parse a Buzz command response from the relay's OK message content.
///
/// Command kinds (e.g. 41010, 30620, 46020) return `"response:{...}"` in the
/// OK message. Returns `null` if the message is not a command response or the
/// JSON is invalid.
Map<String, dynamic>? parseCommandResponse(String message) {
// Try the spec format first: "response:{...}".
const prefix = 'response:';
if (message.startsWith(prefix)) {
try {
final decoded = jsonDecode(message.substring(prefix.length));
if (decoded is Map<String, dynamic>) return decoded;
} catch (_) {}
return null;
}
// Fallback: raw JSON object (older relays, backward compat).
try {
final decoded = jsonDecode(message);
if (decoded is Map<String, dynamic>) return decoded;
} catch (_) {}
return null;
}
+14
View File
@@ -0,0 +1,14 @@
export 'app_lifecycle_provider.dart';
export 'identity_scoped_prefs.dart';
export 'media_auth.dart';
export 'media_image.dart';
export 'media_upload.dart';
export 'nostr_filters.dart';
export 'nostr_models.dart';
export 'relay_closed_policy.dart';
export 'relay_client.dart';
export 'relay_provider.dart';
export 'relay_rate_limit_gate.dart';
export 'relay_session.dart';
export 'relay_socket.dart';
export 'signed_event_relay.dart';
+47
View File
@@ -0,0 +1,47 @@
import 'package:http/http.dart' as http;
/// Lightweight HTTP context for talking to the Buzz relay.
///
/// In the pure-nostr architecture, all data flow happens over the relay
/// WebSocket. This client now exists only to provide a base URL (and a
/// shared HTTP client) for the media upload endpoint, which is the one
/// remaining HTTP path because Blossom uses kind:24242 NIP-98 auth on a
/// regular HTTP PUT.
class RelayClient {
final String baseUrl;
final http.Client _http;
RelayClient({required this.baseUrl, http.Client? httpClient})
: _http = httpClient ?? http.Client();
/// Shared underlying HTTP client (used by [MediaUploader]).
http.Client get httpClient => _http;
/// Fully-qualified URL for the relay's Blossom-style media upload endpoint.
String get mediaUploadUrl {
final base = Uri.parse(baseUrl);
return base.resolve('/upload').toString();
}
void dispose() => _http.close();
}
/// Thrown when an HTTP call to the relay returns a non-2xx status code.
///
/// Retained for backwards compatibility with provider code that still
/// references it during the migration to pure-nostr WebSocket flows.
class RelayException implements Exception {
final int statusCode;
final String body;
RelayException(this.statusCode, this.body);
@override
String toString() {
final trimmedBody = body.trim();
if (trimmedBody.isEmpty) {
return 'RelayException($statusCode)';
}
return 'RelayException($statusCode): $trimmedBody';
}
}
@@ -0,0 +1,39 @@
/// Recovery policy for a relay `CLOSED` subscription message.
enum RelayClosedClass {
/// A transient failure that may recover when the same REQ is retried.
retryable,
/// Relay back-pressure that must also arm the shared request gate.
rateLimited,
/// An authorization, access, or filter failure that cannot recover unchanged.
terminal,
}
/// Classifies whether a relay `CLOSED` message should be retried.
RelayClosedClass classifyRelayClosed(String message) {
final normalized = message.trim().toLowerCase();
if (normalized.startsWith('rate-limited:')) {
return RelayClosedClass.rateLimited;
}
if (normalized.startsWith('restricted:') ||
normalized.startsWith('auth-required:') ||
normalized.startsWith('blocked:') ||
normalized.startsWith('invalid:') ||
normalized.startsWith('pow:') ||
normalized.startsWith('duplicate:') ||
normalized.startsWith('unsupported:') ||
normalized.startsWith('error: mixed search') ||
normalized.startsWith('error: too many subscriptions')) {
return RelayClosedClass.terminal;
}
return RelayClosedClass.retryable;
}
final _rateLimitRetryPattern = RegExp(r'retry in (\d+)s', caseSensitive: false);
/// Parses the relay's canonical `retry in Ns` hint, when present.
int? parseRateLimitRetrySeconds(String message) {
final match = _rateLimitRetryPattern.firstMatch(message);
return match == null ? null : int.tryParse(match.group(1)!);
}
+126
View File
@@ -0,0 +1,126 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import '../community/community_provider.dart';
import 'relay_client.dart';
/// Relay connection configuration.
///
/// In the pure-nostr world the only secrets the app cares about are:
/// - `baseUrl` — where the relay lives (used for WS + media upload)
/// - `nsec` — the user's signing key (drives NIP-42 AUTH and event sigs)
class RelayConfig {
const RelayConfig({required String baseUrl, this.nsec}) : _baseUrl = baseUrl;
/// Relay origin exactly as the active community stored it.
final String _baseUrl;
/// Nostr secret key (bech32 nsec) for signing events and NIP-42 AUTH.
final String? nsec;
/// The origin as persisted, before scheme canonicalization.
///
/// Exists solely so identity-scoped storage keys written before [baseUrl]
/// was canonicalized stay reachable — see [readMigratedPref]. Never use it
/// for network I/O; [baseUrl] and [wsUrl] are the addresses to connect to.
String get storedOrigin => _baseUrl;
/// Relay origin as an HTTP(S) URL.
///
/// Communities are persisted with whichever scheme their onboarding flow
/// used: device pairing stores `https://` (it rejects anything else), while
/// an invite join stores the `wss://` relay URL carried by the invite link.
/// Every consumer treats this as an HTTP origin — [wsUrl], the `/query`
/// endpoint, media upload and Blossom auth — so a `wss://` base silently
/// degrades all of them. Folding the websocket schemes back here keeps both
/// onboarding paths equivalent, including for already-persisted communities.
///
/// Derived rather than normalized in the constructor so that the constructor
/// stays `const`: the compile-time fallback below relies on canonicalization
/// to keep its identity stable across rebuilds, and Riverpod's default
/// `updateShouldNotify` is `previous != next`, which falls back to identity
/// here. A fresh instance per rebuild would resubscribe every listener.
String get baseUrl {
final uri = Uri.tryParse(_baseUrl);
if (uri == null) return _baseUrl;
final scheme = switch (uri.scheme) {
'wss' => 'https',
'ws' => 'http',
_ => null,
};
return scheme == null ? _baseUrl : uri.replace(scheme: scheme).toString();
}
/// Derive the websocket URL from the HTTP base URL.
String get wsUrl {
final uri = Uri.parse(baseUrl);
final scheme = uri.scheme == 'https' ? 'wss' : 'ws';
return uri.replace(scheme: scheme).toString();
}
}
/// Compile-time environment config via --dart-define.
///
/// Run with:
/// flutter run --dart-define=BUZZ_RELAY_URL=http://localhost:3000
///
/// Or create a `.env.json` and use --dart-define-from-file=.env.json
class Env {
static const relayUrl = String.fromEnvironment(
'BUZZ_RELAY_URL',
defaultValue: 'http://localhost:3000',
);
}
class RelayConfigNotifier extends Notifier<RelayConfig> {
@override
RelayConfig build() {
// Watch the active community so that when it changes (community switch),
// the config rebuilds, triggering the full provider cascade.
final activeAsync = ref.watch(activeCommunityProvider);
final active = activeAsync.value;
if (active != null) {
return RelayConfig(baseUrl: active.relayUrl, nsec: active.nsec);
}
// Fallback to compile-time env config (dev mode).
return const RelayConfig(baseUrl: Env.relayUrl);
}
void update({required String baseUrl, String? nsec}) {
state = RelayConfig(baseUrl: baseUrl, nsec: nsec);
}
}
final relayConfigProvider = NotifierProvider<RelayConfigNotifier, RelayConfig>(
RelayConfigNotifier.new,
);
/// Derive the hex pubkey from a bech32 nsec, or null on any failure.
String? pubkeyFromNsec(String? nsec) {
if (nsec == null || nsec.isEmpty) return null;
try {
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
if (privkeyHex.isEmpty) return null;
return nostr.Keys(privkeyHex).public;
} catch (_) {
return null;
}
}
/// The current user's hex pubkey, derived from the active community nsec.
final myPubkeyProvider = Provider<String?>((ref) {
final config = ref.watch(relayConfigProvider);
return pubkeyFromNsec(config.nsec);
});
/// Provides a [RelayClient] that reacts to config changes.
///
/// Only used for the media upload HTTP endpoint now — all data flow goes
/// through the relay WebSocket session.
final relayClientProvider = Provider<RelayClient>((ref) {
final config = ref.watch(relayConfigProvider);
final client = RelayClient(baseUrl: config.baseUrl);
ref.onDispose(client.dispose);
return client;
});
@@ -0,0 +1,80 @@
import 'dart:async';
import 'dart:math';
/// Creates a timer used by [RelayRateLimitGate].
typedef RelayTimerFactory =
Timer Function(Duration duration, void Function() callback);
/// Session-owned gate that pauses relay requests after back-pressure.
class RelayRateLimitGate {
/// Default gate duration when the relay omits a positive retry hint.
static const defaultRetrySeconds = 10;
/// Longest retry hint accepted from a relay response.
static const maxRetrySeconds = 300;
RelayRateLimitGate({
DateTime Function()? now,
RelayTimerFactory timerFactory = Timer.new,
}) : _now = now ?? DateTime.now,
_timerFactory = timerFactory;
final DateTime Function() _now;
final RelayTimerFactory _timerFactory;
DateTime? _expiresAt;
Timer? _timer;
Completer<void>? _completer;
/// Whether a rate-limit window is currently active.
bool get isActive {
final expiresAt = _expiresAt;
return expiresAt != null && _now().isBefore(expiresAt);
}
/// Activates or extends the gate without shrinking an existing window.
void activate(int? retryInSeconds) {
final seconds = retryInSeconds != null && retryInSeconds > 0
? min(retryInSeconds, maxRetrySeconds)
: defaultRetrySeconds;
final duration = Duration(seconds: seconds);
final newExpiry = _now().add(duration);
final currentExpiry = _expiresAt;
if (currentExpiry != null && !newExpiry.isAfter(currentExpiry)) return;
_expiresAt = newExpiry;
_timer?.cancel();
_completer ??= Completer<void>();
_timer = _timerFactory(duration, _expire);
}
/// Resolves when the active rate-limit window expires.
Future<void> wait() {
if (!isActive) return Future.value();
return _completer!.future;
}
/// Milliseconds remaining in the active window, or zero when inactive.
int remainingMs() {
final expiresAt = _expiresAt;
if (expiresAt == null) return 0;
return max(0, expiresAt.difference(_now()).inMilliseconds);
}
/// Clears the gate and releases all current waiters.
void reset() {
_timer?.cancel();
_timer = null;
_expiresAt = null;
final completer = _completer;
_completer = null;
if (completer != null && !completer.isCompleted) completer.complete();
}
void _expire() {
_timer = null;
_expiresAt = null;
final completer = _completer;
_completer = null;
if (completer != null && !completer.isCompleted) completer.complete();
}
}
+978
View File
@@ -0,0 +1,978 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:http/http.dart' as http;
import 'package:nostr/nostr.dart' as nostr;
import 'package:pointycastle/digests/sha256.dart';
import 'package:uuid/uuid.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../auth/auth.dart';
import 'nostr_models.dart';
import 'relay_client.dart';
import 'relay_closed_policy.dart';
import 'relay_provider.dart';
import 'relay_rate_limit_gate.dart';
import 'relay_socket.dart';
enum SessionStatus { disconnected, connecting, connected, reconnecting }
@immutable
class SessionState {
final SessionStatus status;
final int reconnectAttempt;
const SessionState({required this.status, this.reconnectAttempt = 0});
}
class _HistorySubscription {
final List<NostrEvent> events = [];
final Completer<List<NostrEvent>> completer;
final Timer timeout;
_HistorySubscription({required this.completer, required this.timeout});
}
class _LiveSubscription {
final NostrFilter filter;
final void Function(NostrEvent) onEvent;
final void Function(String message)? onClosed;
Completer<void>? readyCompleter;
int? lastSeenCreatedAt;
int closedRetryAttempt = 0;
Timer? closedRetryTimer;
_LiveSubscription({
required this.filter,
required this.onEvent,
this.onClosed,
this.readyCompleter,
});
}
class _ClosedRetry {
final _LiveSubscription subscription;
final int generation;
_ClosedRetry({required this.subscription, required this.generation});
}
class _PendingEvent {
final Completer<NostrEvent> completer;
final Timer timeout;
_PendingEvent({required this.completer, required this.timeout});
}
class _BufferedEvent {
final String subId;
final NostrEvent event;
_BufferedEvent(this.subId, this.event);
}
/// Manages websocket subscriptions, event batching, reconnection with replay,
/// and pending event tracking. Equivalent to the desktop's RelayClientSession.
typedef RelaySocketFactory =
RelaySocket Function({
required String wsUrl,
required String? nsec,
required void Function(List<dynamic> message) onMessage,
required void Function() onConnected,
required void Function(Object? error) onDisconnected,
});
class RelaySessionNotifier extends Notifier<SessionState> {
RelaySessionNotifier({
http.Client? httpClient,
RelaySocketFactory socketFactory = RelaySocket.new,
DateTime Function()? now,
RelayRateLimitGate? rateLimitGate,
RelayTimerFactory retryTimerFactory = Timer.new,
Future<void> Function(Duration) replayDelay = Future.delayed,
}) : _httpClient = httpClient,
_socketFactory = socketFactory,
_now = now ?? DateTime.now,
_rateLimitGate = rateLimitGate ?? RelayRateLimitGate(),
_retryTimerFactory = retryTimerFactory,
_replayDelay = replayDelay;
final http.Client? _httpClient;
final RelaySocketFactory _socketFactory;
final DateTime Function() _now;
final RelayRateLimitGate _rateLimitGate;
final RelayTimerFactory _retryTimerFactory;
final Future<void> Function(Duration) _replayDelay;
static const _baseReconnectDelayMs = 1000;
static const _maxReconnectDelayMs = 30000;
static const _eventBatchMs = 16;
static const _reconnectReplaySkewSeconds = 5;
static const _replayBatchSize = 8;
static const _replayInterBatchDelay = Duration(milliseconds: 50);
static const _maxRecentDeliveryKeys = 5000;
static const _backgroundGraceDuration = Duration(seconds: 5);
RelaySocket? _socket;
final Map<String, _HistorySubscription> _historySubscriptions = {};
final Map<String, _LiveSubscription> _liveSubscriptions = {};
final Map<String, _ClosedRetry> _pendingClosedRetries = {};
final Map<String, _PendingEvent> _pendingEvents = {};
final List<_BufferedEvent> _eventBuffer = [];
final Set<String> _recentDeliveryKeys = {};
Timer? _reconnectTimer;
Timer? _flushTimer;
Timer? _backgroundGraceTimer;
DateTime? _backgroundedAt;
int _reconnectDelayMs = _baseReconnectDelayMs;
int _subIdCounter = 0;
bool _disposed = false;
bool _paused = false;
bool _hasConnectedOnce = false;
int _connectionGeneration = 0;
final Map<Object, String> _visibleChannelsByOwner = {};
bool _socketConnected = false;
bool _closedRetryReplayScheduled = false;
@override
SessionState build() {
final config = ref.watch(relayConfigProvider);
final authState = ref.watch(authProvider);
// Reset disposed flag — build() may re-run on the same Notifier instance
// after a provider dependency changes (e.g. auth completing).
_disposed = false;
ref.onDispose(_dispose);
// Auto-connect when authenticated and we have a signing key (NIP-42 AUTH).
final isAuthenticated = authState.value?.status == AuthStatus.authenticated;
if (isAuthenticated && config.nsec != null) {
// Schedule connection after build completes.
Future.microtask(() => _connect(config));
}
return const SessionState(status: SessionStatus.disconnected);
}
/// Execute a one-shot query via the relay's HTTP bridge (`POST /query`).
Future<List<NostrEvent>> queryRelay(
List<NostrFilter> filters, {
Duration timeout = const Duration(seconds: 8),
}) async {
final config = ref.read(relayConfigProvider);
final url = Uri.parse(config.baseUrl).resolve('/query').toString();
final bodyBytes = utf8.encode(
jsonEncode(filters.map((filter) => filter.toJson()).toList()),
);
final client = _httpClient ?? http.Client();
final shouldCloseClient = _httpClient == null;
final response = await client
.post(
Uri.parse(url),
headers: {
'Authorization': buildNip98AuthHeader(
method: 'POST',
url: url,
bodyBytes: bodyBytes,
nsec: config.nsec,
),
'Content-Type': 'application/json',
},
body: bodyBytes,
)
.timeout(timeout)
.whenComplete(() {
if (shouldCloseClient) client.close();
});
if (response.statusCode < 200 || response.statusCode >= 300) {
_activateRateLimitGateFromHttpError(response.body);
throw RelayException(response.statusCode, response.body);
}
final decoded = jsonDecode(response.body);
if (decoded is! List) {
throw const FormatException('relay returned malformed query response');
}
try {
return [
for (final eventJson in decoded)
if (eventJson is Map<String, dynamic>)
NostrEvent.fromJson(eventJson)
else
throw const FormatException('relay returned malformed query event'),
];
} catch (error) {
if (error is FormatException) rethrow;
throw FormatException('relay returned malformed query event: $error');
}
}
void _activateRateLimitGateFromHttpError(String body) {
final dynamic decoded;
try {
decoded = jsonDecode(body);
} on FormatException {
return;
}
if (decoded is! Map<String, dynamic>) return;
final message = decoded['error'];
if (message is! String ||
classifyRelayClosed(message) != RelayClosedClass.rateLimited) {
return;
}
_rateLimitGate.activate(parseRateLimitRetrySeconds(message));
}
/// Fetch historical events matching [filter]. Sends REQ, collects events
/// until EOSE, then resolves. One-shot subscription.
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async {
if (_rateLimitGate.isActive) await _rateLimitGate.wait();
if (_disposed) throw StateError('Relay session is disposed');
final subId = _nextSubId('h');
final completer = Completer<List<NostrEvent>>();
final timer = Timer(timeout, () {
final sub = _historySubscriptions.remove(subId);
if (sub != null && !sub.completer.isCompleted) {
sub.completer.completeError(
TimeoutException('Relay history request timed out after $timeout'),
);
}
_sendClose(subId);
});
_historySubscriptions[subId] = _HistorySubscription(
completer: completer,
timeout: timer,
);
_sendReq(subId, filter);
return completer.future;
}
/// Subscribe to live events matching [filter]. Returns an unsubscribe
/// function. Live subscriptions survive reconnects — they are replayed with
/// `since: lastSeenCreatedAt - 5s` on reconnect.
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String message)? onClosed,
}) async {
if (_disposed) throw StateError('Relay session is disposed');
final subId = _nextSubId('l');
final readyCompleter = Completer<void>();
_liveSubscriptions[subId] = _LiveSubscription(
filter: filter,
onEvent: onEvent,
onClosed: onClosed,
readyCompleter: readyCompleter,
);
_sendReq(subId, filter);
// Wait for EOSE or a short fallback timeout.
try {
await readyCompleter.future.timeout(
const Duration(milliseconds: 500),
onTimeout: () {},
);
} catch (_) {
_liveSubscriptions.remove(subId);
_recentDeliveryKeys.removeWhere((key) => key.startsWith('$subId:'));
rethrow;
}
final liveSub = _liveSubscriptions[subId];
if (liveSub != null && liveSub.readyCompleter == readyCompleter) {
liveSub.readyCompleter = null;
}
return () => _unsubscribe(subId);
}
/// Publish an event and wait for the relay's OK confirmation.
Future<NostrEvent> publish(
NostrEvent event, {
Duration timeout = const Duration(seconds: 8),
}) {
final completer = Completer<NostrEvent>();
final timer = Timer(timeout, () {
final pending = _pendingEvents.remove(event.id);
if (pending != null && !pending.completer.isCompleted) {
pending.completer.completeError(
TimeoutException(
'Event ${event.id} not acknowledged within $timeout',
),
);
}
});
_pendingEvents[event.id] = _PendingEvent(
completer: completer,
timeout: timer,
);
_socket?.send(['EVENT', event.toJson()]);
return completer.future;
}
/// Send a raw message over the WebSocket without waiting for acknowledgement.
/// Used for ephemeral events like typing indicators.
void sendRaw(List<dynamic> payload) {
_socket?.send(payload);
}
@visibleForTesting
void debugHandleMessage(List<dynamic> data) => _handleMessage(data);
@visibleForTesting
void debugFlushEventBuffer() => _flushEventBuffer();
@visibleForTesting
Future<void> debugHandleConnected() =>
_handleConnected(_connectionGeneration);
@visibleForTesting
Future<void> debugReplayLiveSubscriptions() =>
_replayLiveSubscriptions(_connectionGeneration);
@visibleForTesting
void debugDispose() => _dispose();
@visibleForTesting
void debugSupersedeConnection() => _connectionGeneration++;
@visibleForTesting
void debugHandleDisconnected([Object? error]) {
_socketConnected = false;
_handleDisconnected(_connectionGeneration, error);
}
@visibleForTesting
void debugResetClosedRetriesForDisconnect() {
_socketConnected = false;
_resetAllClosedRetries();
}
@visibleForTesting
void debugSetSessionStatus(SessionStatus status) {
_socketConnected = status == SessionStatus.connected;
}
@visibleForTesting
void debugPauseNow() => _pauseNow();
@visibleForTesting
void debugHandleSocketMessageForTest(List<dynamic> data) =>
_handleMessage(data);
@visibleForTesting
void debugAttachSocketForTest(RelaySocket socket) {
_socket?.dispose();
_socket = socket;
_socketConnected = true;
}
/// Registers a visible channel and returns an owner-scoped release callback.
/// The most recently registered owner is prioritized during reconnect replay.
void Function() registerVisibleChannel(String channelId) {
final owner = Object();
_visibleChannelsByOwner[owner] = channelId;
return () => _visibleChannelsByOwner.remove(owner);
}
/// Force a reconnect (e.g., returning from background).
Future<void> reconnect() async {
_socketConnected = false;
await _socket?.disconnect();
_reconnectDelayMs = _baseReconnectDelayMs;
final config = ref.read(relayConfigProvider);
await _connect(config);
}
/// Called by the app lifecycle provider when the app goes to background.
void onAppPaused() {
_backgroundedAt = _now();
_backgroundGraceTimer?.cancel();
_backgroundGraceTimer = Timer(_backgroundGraceDuration, _pauseNow);
}
void _pauseNow() {
_paused = true;
_socketConnected = false;
_reconnectTimer?.cancel();
_cancelAllHistory(Exception('App moved to background'));
_rejectAllPending(Exception('App moved to background'));
_socket?.disconnect();
state = const SessionState(status: SessionStatus.disconnected);
}
/// Called by the app lifecycle provider when the app returns to foreground.
void onAppResumed() {
_paused = false;
final backgroundedAt = _backgroundedAt;
_backgroundedAt = null;
_backgroundGraceTimer?.cancel();
_backgroundGraceTimer = null;
final backgroundedLongEnoughToRequireReconnect =
backgroundedAt != null &&
_now().difference(backgroundedAt) >= _backgroundGraceDuration;
if (!backgroundedLongEnoughToRequireReconnect &&
state.status == SessionStatus.connected) {
return;
}
// Cancel any in-flight reconnect backoff timer so we reconnect immediately
// instead of waiting for the (possibly large) exponential delay.
_reconnectTimer?.cancel();
_reconnectDelayMs = _baseReconnectDelayMs;
final config = ref.read(relayConfigProvider);
_connect(config);
}
Future<void> _connect(RelayConfig config) async {
if (_disposed) return;
final generation = ++_connectionGeneration;
state = SessionState(
status: _hasConnectedOnce
? SessionStatus.reconnecting
: SessionStatus.connecting,
reconnectAttempt: state.reconnectAttempt,
);
_socket?.dispose();
final socket = _socketFactory(
wsUrl: config.wsUrl,
nsec: config.nsec,
onMessage: (message) {
if (generation == _connectionGeneration) _handleMessage(message);
},
onConnected: () => _handleConnected(generation),
onDisconnected: (error) => _handleDisconnected(generation, error),
);
_socket = socket;
await socket.connect();
}
Future<void> _handleConnected(int generation) async {
if (_disposed || generation != _connectionGeneration) return;
_socketConnected = true;
_hasConnectedOnce = true;
_reconnectDelayMs = _baseReconnectDelayMs;
state = const SessionState(status: SessionStatus.connected);
await _replayLiveSubscriptions(generation);
}
void _handleDisconnected(int generation, Object? error) {
if (_disposed || generation != _connectionGeneration) return;
_socketConnected = false;
_cancelAllHistory(error);
_rejectAllPending(error);
_resetAllClosedRetries();
_eventBuffer.clear();
_flushTimer?.cancel();
_flushTimer = null;
if (error is RelayAuthRejectedException) {
_reconnectTimer?.cancel();
state = const SessionState(status: SessionStatus.disconnected);
return;
}
_scheduleReconnect();
}
void _scheduleReconnect() {
if (_disposed || _paused) return;
final attempt = state.reconnectAttempt + 1;
state = SessionState(
status: SessionStatus.reconnecting,
reconnectAttempt: attempt,
);
_reconnectTimer?.cancel();
_reconnectTimer = Timer(Duration(milliseconds: _reconnectDelayMs), () {
_reconnectDelayMs = min(_reconnectDelayMs * 2, _maxReconnectDelayMs);
final config = ref.read(relayConfigProvider);
_connect(config);
});
}
/// Replay all live subscriptions after a reconnect, with a time skew to
/// catch events that occurred during the disconnect.
Future<void> _replayLiveSubscriptions(int generation) async {
if (_rateLimitGate.isActive) await _rateLimitGate.wait();
if (!_isActiveConnection(generation)) return;
final entries = _liveSubscriptions.entries.toList();
final visibleChannelId = _visibleChannelsByOwner.isEmpty
? null
: _visibleChannelsByOwner.values.last;
if (visibleChannelId != null) {
entries.sort((left, right) {
final leftVisible =
left.value.filter.tags['#h']?.contains(visibleChannelId) ?? false;
final rightVisible =
right.value.filter.tags['#h']?.contains(visibleChannelId) ?? false;
if (leftVisible == rightVisible) return 0;
return leftVisible ? -1 : 1;
});
}
await _sendReplayBatches(entries, generation);
}
Future<void> _replayPendingClosedRetries(int generation) async {
if (!_isActiveConnection(generation)) return;
final entries = _pendingClosedRetries.entries
.where((entry) => entry.value.generation == generation)
.map(
(entry) => MapEntry<String, _LiveSubscription>(
entry.key,
entry.value.subscription,
),
)
.toList();
await _sendReplayBatches(entries, generation, pendingClosedRetries: true);
}
Future<void> _sendReplayBatches(
List<MapEntry<String, _LiveSubscription>> entries,
int generation, {
bool pendingClosedRetries = false,
}) async {
for (var i = 0; i < entries.length; i += _replayBatchSize) {
if (_rateLimitGate.isActive) await _rateLimitGate.wait();
if (!_isActiveConnection(generation)) return;
final batch = entries.sublist(
i,
min(i + _replayBatchSize, entries.length),
);
for (final entry in batch) {
if (_liveSubscriptions[entry.key] != entry.value) continue;
if (pendingClosedRetries) {
final pendingRetry = _pendingClosedRetries[entry.key];
if (pendingRetry?.subscription != entry.value ||
pendingRetry?.generation != generation) {
continue;
}
_pendingClosedRetries.remove(entry.key);
}
_sendReq(entry.key, _replayFilter(entry.value));
}
if (i + _replayBatchSize < entries.length) {
await _replayDelay(_replayInterBatchDelay);
}
}
}
bool _isActiveConnection(int generation) =>
!_disposed && generation == _connectionGeneration;
NostrFilter _replayFilter(_LiveSubscription subscription) {
final since = subscription.lastSeenCreatedAt;
return since == null
? subscription.filter
: subscription.filter.copyWithSince(
max(0, since - _reconnectReplaySkewSeconds),
);
}
void _handleMessage(List<dynamic> data) {
if (data.isEmpty) return;
final type = data[0] as String;
switch (type) {
case 'EVENT':
_handleEvent(data);
case 'EOSE':
_handleEose(data);
case 'CLOSED':
_handleClosed(data);
case 'OK':
_handleOk(data);
}
}
void _handleEvent(List<dynamic> data) {
if (data.length < 3) return;
final subId = data[1] as String;
final eventJson = data[2] as Map<String, dynamic>;
final event = NostrEvent.fromJson(eventJson);
// History subscriptions accumulate immediately.
final historySub = _historySubscriptions[subId];
if (historySub != null) {
historySub.events.add(event);
return;
}
// Live subscriptions get batched.
final liveSub = _liveSubscriptions[subId];
if (liveSub != null) {
_resetClosedRetry(liveSub);
// Track last seen timestamp for reconnect replay.
if (liveSub.lastSeenCreatedAt == null ||
event.createdAt > liveSub.lastSeenCreatedAt!) {
liveSub.lastSeenCreatedAt = event.createdAt;
}
_eventBuffer.add(_BufferedEvent(subId, event));
_scheduleFlush();
}
}
void _handleEose(List<dynamic> data) {
if (data.length < 2) return;
final subId = data[1] as String;
// History subscription: resolve with collected events.
final historySub = _historySubscriptions.remove(subId);
if (historySub != null) {
historySub.timeout.cancel();
if (!historySub.completer.isCompleted) {
historySub.completer.complete(historySub.events);
}
_sendClose(subId);
return;
}
// Live subscription: signal ready.
final liveSub = _liveSubscriptions[subId];
if (liveSub != null) {
_resetClosedRetry(liveSub);
}
if (liveSub != null &&
liveSub.readyCompleter != null &&
!liveSub.readyCompleter!.isCompleted) {
// EOSE is the boundary between replay and live delivery. Flush any
// replay events before resolving subscribe(), so callers that begin a
// one-shot query immediately afterwards cannot classify a delayed batch
// callback as having arrived during that query.
_flushBufferedEventsNow();
liveSub.readyCompleter!.complete();
liveSub.readyCompleter = null;
}
}
void _handleClosed(List<dynamic> data) {
if (data.length < 2) return;
final subId = data[1] as String;
final message = data.length >= 3 && data[2] is String
? data[2] as String
: 'subscription closed by relay';
final closedClass = classifyRelayClosed(message);
final historySub = _historySubscriptions.remove(subId);
if (historySub != null) {
if (closedClass == RelayClosedClass.rateLimited) {
_rateLimitGate.activate(parseRateLimitRetrySeconds(message));
}
historySub.timeout.cancel();
if (!historySub.completer.isCompleted) {
historySub.completer.completeError(Exception(message));
}
return;
}
final liveSub = _liveSubscriptions[subId];
if (liveSub == null) return;
final readyCompleter = liveSub.readyCompleter;
if (closedClass == RelayClosedClass.terminal) {
if (readyCompleter != null && !readyCompleter.isCompleted) {
readyCompleter.completeError(Exception(message));
}
liveSub.onClosed?.call(message);
_removeLiveSubscription(subId, liveSub);
return;
}
if (readyCompleter != null && !readyCompleter.isCompleted) {
readyCompleter.complete();
liveSub.readyCompleter = null;
}
if (liveSub.closedRetryTimer != null) return;
final attempt = liveSub.closedRetryAttempt;
final backoffMs = attempt >= 5
? _maxReconnectDelayMs
: _baseReconnectDelayMs * (1 << attempt);
var delayMs = backoffMs;
if (closedClass == RelayClosedClass.rateLimited) {
final retrySeconds = parseRateLimitRetrySeconds(message);
_rateLimitGate.activate(retrySeconds);
final fallbackMs =
(retrySeconds != null && retrySeconds > 0
? min(retrySeconds, RelayRateLimitGate.maxRetrySeconds)
: RelayRateLimitGate.defaultRetrySeconds) *
1000;
delayMs = max(
backoffMs,
_rateLimitGate.remainingMs() == 0
? fallbackMs
: _rateLimitGate.remainingMs(),
);
}
liveSub.closedRetryAttempt = attempt + 1;
final retryGeneration = _connectionGeneration;
liveSub.closedRetryTimer = _retryTimerFactory(
Duration(milliseconds: delayMs),
() async {
liveSub.closedRetryTimer = null;
if (!_isActiveConnection(retryGeneration) ||
_liveSubscriptions[subId] != liveSub) {
return;
}
if (_rateLimitGate.isActive) await _rateLimitGate.wait();
if (!_isActiveConnection(retryGeneration) ||
_liveSubscriptions[subId] != liveSub ||
!_socketConnected) {
return;
}
_pendingClosedRetries[subId] = _ClosedRetry(
subscription: liveSub,
generation: retryGeneration,
);
_scheduleClosedRetryReplay(retryGeneration);
},
);
}
void _scheduleClosedRetryReplay(int generation) {
if (_closedRetryReplayScheduled) return;
_closedRetryReplayScheduled = true;
scheduleMicrotask(() async {
try {
await _replayPendingClosedRetries(generation);
} finally {
_closedRetryReplayScheduled = false;
_pendingClosedRetries.removeWhere(
(_, retry) => retry.generation != _connectionGeneration,
);
if (_pendingClosedRetries.values.any(
(retry) => retry.generation == _connectionGeneration,
)) {
_scheduleClosedRetryReplay(_connectionGeneration);
}
}
});
}
void _handleOk(List<dynamic> data) {
if (data.length < 3) return;
final eventId = data[1] as String;
final accepted = data[2] as bool;
final message = data.length > 3 && data[3] is String
? data[3] as String
: '';
final pending = _pendingEvents.remove(eventId);
if (pending == null) return;
pending.timeout.cancel();
if (accepted) {
// We don't have the full event here; create a minimal placeholder.
// Command kinds (e.g. 41010, 30620, 46020) return "response:{...}" in
// the OK message — preserve it in `content` so callers can parse it.
if (!pending.completer.isCompleted) {
pending.completer.complete(
NostrEvent(
id: eventId,
pubkey: '',
createdAt: 0,
kind: 0,
tags: [],
content: message,
sig: '',
),
);
}
} else {
if (!pending.completer.isCompleted) {
pending.completer.completeError(
Exception(message.isNotEmpty ? message : 'Event rejected'),
);
}
}
}
void _scheduleFlush() {
_flushTimer ??= Timer(
const Duration(milliseconds: _eventBatchMs),
_flushEventBuffer,
);
}
void _flushBufferedEventsNow() {
_flushTimer?.cancel();
_flushTimer = null;
_flushEventBuffer();
}
void _flushEventBuffer() {
_flushTimer = null;
if (_eventBuffer.isEmpty) return;
final batch = List<_BufferedEvent>.from(_eventBuffer);
_eventBuffer.clear();
for (final buffered in batch) {
final sub = _liveSubscriptions[buffered.subId];
if (sub == null) continue;
// Deduplicate per subscription. The same relay event can legitimately
// match multiple live subscriptions, e.g. the channel list unread listener
// and the open channel message listener.
final deliveryKey = '${buffered.subId}:${buffered.event.id}';
if (_recentDeliveryKeys.contains(deliveryKey)) continue;
// Cap the dedup set to prevent unbounded memory growth.
if (_recentDeliveryKeys.length >= _maxRecentDeliveryKeys) {
_recentDeliveryKeys.clear();
}
_recentDeliveryKeys.add(deliveryKey);
sub.onEvent(buffered.event);
}
}
String _nextSubId(String prefix) {
_subIdCounter++;
return '$prefix-$_subIdCounter';
}
void _sendReq(String subId, NostrFilter filter) {
_socket?.send(['REQ', subId, filter.toJson()]);
}
void _sendClose(String subId) {
_socket?.send(['CLOSE', subId]);
}
void _unsubscribe(String subId) {
final subscription = _liveSubscriptions[subId];
if (subscription != null) {
_removeLiveSubscription(subId, subscription);
}
_sendClose(subId);
}
void _removeLiveSubscription(String subId, _LiveSubscription subscription) {
if (_liveSubscriptions[subId] != subscription) return;
_liveSubscriptions.remove(subId);
_pendingClosedRetries.remove(subId);
subscription.closedRetryTimer?.cancel();
subscription.closedRetryTimer = null;
_recentDeliveryKeys.removeWhere((key) => key.startsWith('$subId:'));
}
void _resetClosedRetry(_LiveSubscription subscription) {
subscription.closedRetryAttempt = 0;
subscription.closedRetryTimer?.cancel();
subscription.closedRetryTimer = null;
}
void _cancelAllClosedRetries() {
_pendingClosedRetries.clear();
for (final subscription in _liveSubscriptions.values) {
subscription.closedRetryTimer?.cancel();
subscription.closedRetryTimer = null;
}
}
void _resetAllClosedRetries() {
_pendingClosedRetries.clear();
for (final subscription in _liveSubscriptions.values) {
_resetClosedRetry(subscription);
}
}
void _cancelAllHistory(Object? error) {
for (final entry in _historySubscriptions.values) {
entry.timeout.cancel();
if (!entry.completer.isCompleted) {
entry.completer.completeError(error ?? Exception('Connection lost'));
}
}
_historySubscriptions.clear();
}
void _rejectAllPending(Object? error) {
for (final entry in _pendingEvents.values) {
entry.timeout.cancel();
if (!entry.completer.isCompleted) {
entry.completer.completeError(error ?? Exception('Connection lost'));
}
}
_pendingEvents.clear();
}
void _dispose() {
_disposed = true;
_connectionGeneration++;
_reconnectTimer?.cancel();
_flushTimer?.cancel();
_backgroundGraceTimer?.cancel();
_backgroundedAt = null;
_cancelAllClosedRetries();
_rateLimitGate.reset();
_visibleChannelsByOwner.clear();
_socketConnected = false;
_cancelAllHistory(null);
_rejectAllPending(null);
final subscriptions = _liveSubscriptions.values.toList();
_liveSubscriptions.clear();
for (final subscription in subscriptions) {
subscription.closedRetryTimer?.cancel();
subscription.closedRetryTimer = null;
}
_recentDeliveryKeys.clear();
_socket?.dispose();
_socket = null;
_httpClient?.close();
}
}
final relaySessionProvider =
NotifierProvider<RelaySessionNotifier, SessionState>(
RelaySessionNotifier.new,
);
String buildNip98AuthHeader({
required String method,
required String url,
required List<int> bodyBytes,
required String? nsec,
}) {
if (nsec == null || nsec.isEmpty) {
throw Exception('Cannot query relay: no signing key available');
}
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
if (privkeyHex.isEmpty) {
throw Exception('Invalid nsec');
}
final payloadHash = SHA256Digest()
.process(Uint8List.fromList(bodyBytes))
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
final event = nostr.Event.from(
kind: 27235,
content: '',
tags: [
['u', url],
['method', method.toUpperCase()],
['payload', payloadHash],
['nonce', const Uuid().v4()],
],
secretKey: privkeyHex,
verify: false,
);
return 'Nostr ${base64.encode(utf8.encode(event.toJson()))}';
}
+261
View File
@@ -0,0 +1,261 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:web_socket_channel/io.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'nostr_models.dart';
/// Low-level websocket connection with NIP-42 authentication.
///
/// Handles the raw websocket lifecycle: connect, authenticate via NIP-42
/// challenge/response, send/receive JSON frames, and disconnect.
///
/// Does NOT handle reconnection — that is [RelaySessionNotifier]'s job.
enum SocketState { disconnected, connecting, authenticating, connected }
class RelayAuthRejectedException implements Exception {
final String message;
const RelayAuthRejectedException(this.message);
@override
String toString() => 'Relay authentication rejected: $message';
}
Exception classifyRelayAuthFailure(String message) {
if (message.startsWith('error:')) return Exception(message);
return RelayAuthRejectedException(message);
}
class RelaySocket {
/// Interval for sending a ping and awaiting its pong before disconnecting.
static const pingInterval = Duration(seconds: 30);
@visibleForTesting
static Duration debugPingInterval = pingInterval;
final String _wsUrl;
final String? _nsec;
final void Function(List<dynamic> message) _onMessage;
final void Function() _onConnected;
final void Function(Object? error) _onDisconnected;
WebSocketChannel? _channel;
StreamSubscription<dynamic>? _subscription;
SocketState _state = SocketState.disconnected;
Completer<void>? _authCompleter;
Timer? _authTimeout;
String? _pendingAuthEventId;
SocketState get state => _state;
RelaySocket({
required String wsUrl,
required String? nsec,
required void Function(List<dynamic> message) onMessage,
required void Function() onConnected,
required void Function(Object? error) onDisconnected,
}) : _wsUrl = wsUrl,
_nsec = nsec,
_onMessage = onMessage,
_onConnected = onConnected,
_onDisconnected = onDisconnected;
/// Connect to the relay and complete NIP-42 authentication.
Future<void> connect() async {
if (_state != SocketState.disconnected) return;
_state = SocketState.connecting;
try {
_channel = IOWebSocketChannel.connect(
Uri.parse(_wsUrl),
pingInterval: debugPingInterval,
);
await _channel!.ready;
} catch (e) {
_state = SocketState.disconnected;
_onDisconnected(e);
return;
}
// The channel may have been disposed while we were awaiting ready
// (e.g. provider rebuild triggered dispose() concurrently).
if (_channel == null) {
_state = SocketState.disconnected;
return;
}
_state = SocketState.authenticating;
_authCompleter = Completer<void>();
_subscription = _channel!.stream.listen(
_handleRawMessage,
onError: (Object error) {
_failAuth(error);
_resetConnection();
_onDisconnected(error);
},
onDone: () {
_failAuth(null);
_resetConnection();
_onDisconnected(null);
},
);
// Wait for auth to complete (or timeout).
_authTimeout = Timer(const Duration(seconds: 8), () {
if (_authCompleter != null && !_authCompleter!.isCompleted) {
_authCompleter!.completeError(
TimeoutException('NIP-42 auth timed out after 8s'),
);
}
});
try {
await _authCompleter!.future;
_authTimeout?.cancel();
_state = SocketState.connected;
_onConnected();
} catch (e) {
_authTimeout?.cancel();
await disconnect();
_onDisconnected(e);
}
}
/// Send a raw JSON array over the websocket.
void send(List<dynamic> payload) {
_channel?.sink.add(jsonEncode(payload));
}
/// Gracefully close the connection.
Future<void> disconnect() async {
_resetConnection();
final channel = _channel;
_channel = null;
if (channel != null) {
await channel.sink.close();
}
}
void dispose() {
_resetConnection();
_channel?.sink.close();
_channel = null;
}
void _resetConnection() {
_state = SocketState.disconnected;
_subscription?.cancel();
_subscription = null;
_authTimeout?.cancel();
_authTimeout = null;
_pendingAuthEventId = null;
}
void _failAuth(Object? error) {
if (_authCompleter != null && !_authCompleter!.isCompleted) {
_authCompleter!.completeError(error ?? Exception('Connection closed'));
}
}
void _handleRawMessage(dynamic raw) {
final String text;
if (raw is String) {
text = raw;
} else {
return; // Binary frames are not part of the Nostr protocol.
}
final List<dynamic> data;
try {
data = jsonDecode(text) as List<dynamic>;
} catch (_) {
return; // Malformed JSON.
}
if (data.isEmpty) return;
final type = data[0] as String;
switch (type) {
case 'AUTH':
_handleAuthChallenge(data);
case 'OK':
_handleOk(data);
default:
// Pass EVENT, EOSE, NOTICE, etc. upstream.
_onMessage(data);
}
}
/// Handle the relay's AUTH challenge: sign a kind:22242 event and respond.
void _handleAuthChallenge(List<dynamic> data) {
if (data.length < 2) return;
final challenge = data[1] as String;
if (_nsec == null) {
_failAuth(Exception('No nsec available for NIP-42 auth'));
return;
}
try {
// Decode bech32 nsec to hex private key.
final privkeyHex = nostr.Nip19.decode(payload: _nsec).data;
if (privkeyHex.isEmpty) {
_failAuth(Exception('Invalid nsec'));
return;
}
// Build the auth tags.
final tags = <List<String>>[
['relay', _wsUrl],
['challenge', challenge],
];
// Create and sign the kind:22242 AUTH event.
final event = nostr.Event.from(
kind: EventKind.auth,
content: '',
tags: tags,
secretKey: privkeyHex,
);
_pendingAuthEventId = event.id;
send(['AUTH', event.toMap()]);
} catch (e) {
_failAuth(e);
}
}
@visibleForTesting
void debugHandleOkForTest(List<dynamic> data) => _onMessage(data);
/// Handle OK frames. During auth, complete the auth flow.
void _handleOk(List<dynamic> data) {
if (data.length < 3) return;
final eventId = data[1] as String;
final accepted = data[2] as bool;
// Check if this OK is for our pending AUTH event.
if (_pendingAuthEventId != null && eventId == _pendingAuthEventId) {
_pendingAuthEventId = null;
if (accepted) {
if (_authCompleter != null && !_authCompleter!.isCompleted) {
_authCompleter!.complete();
}
} else {
final message = data.length > 3
? data[3] as String
: 'Auth rejected by relay';
_failAuth(classifyRelayAuthFailure(message));
}
return;
}
// Pass non-auth OK frames upstream for pending event tracking.
_onMessage(data);
}
}
@@ -0,0 +1,164 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
/// Validates an untrusted relay origin before the mobile app connects to it.
///
/// Production relay URLs must use TLS and may not contain local or otherwise
/// non-public IP literals. Debug builds retain plaintext localhost support for
/// local development, but other non-public destinations remain blocked.
void validateInviteRelayUri(
Uri uri, {
bool allowInsecureLocalhost = kDebugMode,
}) {
if (uri.host.isEmpty ||
uri.userInfo.isNotEmpty ||
uri.hasFragment ||
(uri.path.isNotEmpty && uri.path != '/') ||
uri.hasQuery) {
throw const FormatException('Relay URL must be an origin');
}
if (uri.scheme != 'ws' && uri.scheme != 'wss') {
throw FormatException('Invalid relay URL scheme: ${uri.scheme}');
}
final host = uri.host.toLowerCase();
final isLocalhost = host == 'localhost' || host.endsWith('.localhost');
if (uri.scheme != 'wss' && !(allowInsecureLocalhost && isLocalhost)) {
throw const FormatException('Relay URL must use WSS');
}
if (isLocalhost) {
if (!allowInsecureLocalhost) {
throw const FormatException('Relay URL cannot target localhost');
}
return;
}
final address = InternetAddress.tryParse(host);
if (_looksLikeNumericAddress(host)) {
// Only canonical dotted-decimal IPv4 is accepted. Ambiguous legacy forms
// such as a single integer, hexadecimal, shortened components, or leading
// zeroes have varied interpretation across URI/network stacks.
final ipv4Parts = host.split('.');
final hasLeadingZero = ipv4Parts.any(
(part) => part.length > 1 && part.startsWith('0'),
);
if (address == null ||
address.type != InternetAddressType.IPv4 ||
address.address != host ||
ipv4Parts.length != 4 ||
hasLeadingZero) {
throw const FormatException('Relay URL contains an invalid IP address');
}
}
if (address != null) {
if (!_isPublicAddress(address)) {
throw const FormatException(
'Relay URL cannot target non-public network addresses',
);
}
return;
}
// DNS hostnames are allowed here. Preventing a hostname from resolving or
// rebinding to a non-public address requires connect-time resolution and
// address pinning in the networking layer.
}
bool _looksLikeNumericAddress(String host) {
if (RegExp(r'^\d+$').hasMatch(host) ||
RegExp(r'^0x[0-9a-f]+$', caseSensitive: false).hasMatch(host)) {
return true;
}
final parts = host.split('.');
return parts.length > 1 &&
parts.every(
(part) =>
RegExp(r'^\d+$').hasMatch(part) ||
RegExp(r'^0x[0-9a-f]+$', caseSensitive: false).hasMatch(part),
);
}
bool _isPublicAddress(InternetAddress address) {
final bytes = address.rawAddress;
if (address.type == InternetAddressType.IPv4) {
return !_isNonPublicIpv4(bytes);
}
if (address.type != InternetAddressType.IPv6 || bytes.length != 16) {
return false;
}
// IPv4-compatible and IPv4-mapped IPv6 addresses inherit the embedded
// IPv4 address's classification.
final firstTenZero = bytes.take(10).every((byte) => byte == 0);
if (firstTenZero &&
((bytes[10] == 0 && bytes[11] == 0) ||
(bytes[10] == 0xff && bytes[11] == 0xff))) {
return !_isNonPublicIpv4(bytes.sublist(12));
}
// Accept only globally reachable IPv6 literals. Global unicast space is
// 2000::/3, with special-purpose exclusions and narrow globally reachable
// exceptions tracked by IANA (reconciled 2026-07-26):
// https://www.iana.org/assignments/iana-ipv6-special-registry/
if (!_hasPrefix(bytes, [0x20], 3)) return false;
// 2001::/23 is reserved for IETF protocol assignments. Only these entries
// are currently classified by IANA as globally reachable.
if (_hasPrefix(bytes, [0x20, 0x01, 0x00], 23) &&
!_isGloballyReachableIetfAssignment(bytes)) {
return false;
}
// Documentation and transition prefixes are not public relay destinations.
if (_hasPrefix(bytes, [0x20, 0x01, 0x0d, 0xb8], 32)) return false;
if (_hasPrefix(bytes, [0x20, 0x02], 16)) return false;
if (_hasPrefix(bytes, [0x3f, 0xff, 0x00], 20)) return false;
return true;
}
bool _isGloballyReachableIetfAssignment(List<int> bytes) {
final isGlobalAnycast =
_hasPrefix(bytes, [0x20, 0x01, 0x00, 0x01, 0, 0, 0, 0], 64) &&
bytes.sublist(8, 15).every((byte) => byte == 0) &&
bytes[15] >= 1 &&
bytes[15] <= 3;
return isGlobalAnycast ||
_hasPrefix(bytes, [0x20, 0x01, 0x00, 0x03], 32) ||
_hasPrefix(bytes, [0x20, 0x01, 0x00, 0x04, 0x01, 0x12], 48) ||
_hasPrefix(bytes, [0x20, 0x01, 0x00, 0x20], 28) ||
_hasPrefix(bytes, [0x20, 0x01, 0x00, 0x30], 28);
}
bool _hasPrefix(List<int> address, List<int> prefix, int prefixLength) {
final wholeBytes = prefixLength ~/ 8;
for (var i = 0; i < wholeBytes; i++) {
if (address[i] != prefix[i]) return false;
}
final remainingBits = prefixLength % 8;
if (remainingBits == 0) return true;
final mask = (0xff << (8 - remainingBits)) & 0xff;
return (address[wholeBytes] & mask) == (prefix[wholeBytes] & mask);
}
bool _isNonPublicIpv4(List<int> bytes) {
if (bytes.length != 4) return true;
final a = bytes[0];
final b = bytes[1];
final c = bytes[2];
return a == 0 ||
a == 10 ||
a == 127 ||
(a == 100 && b >= 64 && b <= 127) ||
(a == 169 && b == 254) ||
(a == 172 && b >= 16 && b <= 31) ||
(a == 192 && b == 0 && c == 0) ||
(a == 192 && b == 0 && c == 2) ||
(a == 192 && b == 168) ||
(a == 198 && (b == 18 || b == 19)) ||
(a == 198 && b == 51 && c == 100) ||
(a == 203 && b == 0 && c == 113) ||
a >= 224;
}
@@ -0,0 +1,59 @@
import 'package:nostr/nostr.dart' as nostr;
import 'nostr_models.dart';
import 'relay_session.dart';
/// Signs and submits Nostr events through the relay WebSocket connection.
class SignedEventRelay {
final RelaySessionNotifier _session;
final String? _nsec;
SignedEventRelay({
required RelaySessionNotifier session,
required String? nsec,
}) : _session = session,
_nsec = nsec;
/// The hex pubkey derived from the signing key, or null if no key.
String? get pubkey {
final nsec = _nsec;
if (nsec == null || nsec.isEmpty) return null;
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
if (privkeyHex.isEmpty) return null;
return nostr.Keys(privkeyHex).public;
}
/// Sign and submit an event. Returns the relay's OK response as a [NostrEvent]
/// whose `content` field contains the OK message (e.g. `"response:{...}"`
/// for command kinds).
Future<NostrEvent> submit({
required int kind,
required String content,
required List<List<String>> tags,
int? createdAt,
void Function(NostrEvent event)? onSigned,
}) async {
final nsec = _nsec;
if (nsec == null || nsec.isEmpty) {
throw Exception('Cannot submit event: no signing key available');
}
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
if (privkeyHex.isEmpty) {
throw Exception('Invalid nsec');
}
final event = nostr.Event.from(
kind: kind,
content: content,
tags: tags,
secretKey: privkeyHex,
createdAt: createdAt,
verify: false,
);
final nostrEvent = NostrEvent.fromJson(event.toMap());
onSigned?.call(nostrEvent);
return _session.publish(nostrEvent);
}
}
@@ -0,0 +1,142 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../l10n/l10n.dart';
import '../theme/theme.dart';
import '../widgets/sheet_divider.dart';
import '../widgets/modal_presentation.dart';
import 'reminder_service.dart';
import 'reminder_time_presets.dart';
/// Bottom sheet for "Remind me later": desktop-parity presets plus a native
/// date/time picker, publishing the same author-private kind-30300 reminder
/// events desktop reads.
void showRemindMeLaterSheet({
required BuildContext context,
required WidgetRef ref,
required ReminderTarget target,
}) {
final service = ref.read(reminderServiceProvider);
final messenger = ScaffoldMessenger.of(context);
if (service == null) {
messenger.showSnackBar(
SnackBar(content: Text(context.l10n.signInSetReminders)),
);
return;
}
Future<void> submit(int notBefore) async {
try {
await service.createReminder(target: target, notBefore: notBefore);
messenger.showSnackBar(SnackBar(content: Text(context.l10n.reminderSet)));
} catch (error) {
// Stable user-facing copy; the relay/crypto detail goes to the log.
debugPrint('[RemindMeLater] createReminder failed: $error');
messenger.showSnackBar(
SnackBar(content: Text(context.l10n.reminderSetFailed)),
);
}
}
showBuzzModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: IconTheme.merge(
data: const IconThemeData(size: 22),
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(
left: Grid.half,
bottom: Grid.xxs,
),
child: Text(
sheetContext.l10n.remindAboutMessage,
style: Theme.of(sheetContext).textTheme.titleSmall,
),
),
for (final preset in reminderTimePresets)
ListTile(
leading: const Icon(LucideIcons.clock),
title: Text(_presetLabel(sheetContext, preset)),
onTap: () {
Navigator.of(sheetContext).pop();
submit(preset.getTimestamp());
},
),
const SheetDivider(),
ListTile(
leading: const Icon(LucideIcons.calendarClock),
title: Text(sheetContext.l10n.pickDateTime),
onTap: () async {
final navigator = Navigator.of(sheetContext);
final timestamp = await _pickCustomDateTime(context);
// Cancelled or not-in-the-future: keep the preset sheet
// open so retrying doesn't mean long-pressing again.
if (timestamp == null) return;
if (navigator.mounted) navigator.pop();
await submit(timestamp);
},
),
],
),
),
),
),
),
);
}
/// Native date + time pickers chained together. Returns a future Unix
/// timestamp in seconds, or null when cancelled or not in the future.
Future<int?> _pickCustomDateTime(BuildContext context) async {
final now = DateTime.now();
final date = await showBuzzDialog<DateTime>(
context: context,
builder: (_) => DatePickerDialog(
initialDate: now,
firstDate: DateTime(now.year, now.month, now.day),
lastDate: now.add(const Duration(days: 365 * 2)),
),
);
if (date == null || !context.mounted) return null;
final time = await showBuzzDialog<TimeOfDay>(
context: context,
builder: (_) =>
const TimePickerDialog(initialTime: TimeOfDay(hour: 9, minute: 0)),
);
if (time == null) return null;
final timestamp = combineCustomDateTime(date, time.hour, time.minute);
if (timestamp == null && context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.pickFutureTime)));
}
return timestamp;
}
String _presetLabel(BuildContext context, ReminderTimePreset preset) {
final l10n = context.l10n;
return switch (preset.id) {
ReminderTimePresetId.in30Minutes => l10n.reminderIn30Minutes,
ReminderTimePresetId.in1Hour => l10n.reminderIn1Hour,
ReminderTimePresetId.in3Hours => l10n.reminderIn3Hours,
ReminderTimePresetId.tomorrow9am => l10n.reminderTomorrow9am,
ReminderTimePresetId.nextMonday9am => l10n.reminderNextMonday9am,
ReminderTimePresetId.legacy => preset.label,
};
}
@@ -0,0 +1,149 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import '../crypto/ecdh.dart';
import '../crypto/nip44.dart';
import '../relay/relay.dart';
/// The message a reminder points back to. Mirrors the desktop
/// `ReminderTarget` shape (`desktop/src/features/reminders/lib/reminderTypes.ts`)
/// so reminders created on mobile are readable by desktop and vice versa.
class ReminderTarget {
/// Event ID of the target message.
final String eventId;
/// Channel ID where the message lives.
final String channelId;
/// Preview text of the target message (truncated).
final String preview;
/// Author pubkey of the target message.
final String authorPubkey;
const ReminderTarget({
required this.eventId,
required this.channelId,
required this.preview,
required this.authorPubkey,
});
Map<String, dynamic> toJson() => {
'eventId': eventId,
'channelId': channelId,
'preview': preview,
'authorPubkey': authorPubkey,
};
}
/// Build the NIP-ER reminder plaintext for a new pending reminder.
///
/// Matches the JSON desktop writes in `reminderService.ts#createReminder`:
/// `{"target": {...}, "note": <optional>, "status": "pending"}`. `note` is
/// omitted entirely when absent so both clients parse each other's payloads.
String buildReminderPlaintext({required ReminderTarget target, String? note}) {
return jsonEncode({
'target': target.toJson(),
if (note != null && note.isNotEmpty) 'note': note,
'status': 'pending',
});
}
/// Generate a reminder `d`-tag with 128 bits of entropy (NIP-ER MUST).
String randomReminderDTag() {
final bytes = secureRandomBytes(16);
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
}
/// Tags for a new pending reminder event: `d` plus strict-decimal
/// `not_before`, exactly as the relay's NIP-ER validator expects.
List<List<String>> buildReminderTags({
required String dTag,
required int notBefore,
}) {
if (notBefore < 0) {
throw ArgumentError('notBefore must be a non-negative Unix timestamp');
}
return [
['d', dTag],
['not_before', '$notBefore'],
];
}
/// NIP-44 self-encryption for author-private reminder payloads. Derives the
/// conversation key to the author's own pubkey, matching the desktop's
/// `nip44_encrypt_to_self` Tauri command.
class ReminderCrypto {
final Uint8List _conversationKey;
ReminderCrypto(String nsec, String pubkey)
: _conversationKey = _deriveKey(nsec, pubkey);
static Uint8List _deriveKey(String nsec, String pubkey) {
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
return getConversationKey(privkeyHex, pubkey);
}
String encrypt(String plaintext) => nip44Encrypt(_conversationKey, plaintext);
String decrypt(String ciphertext) =>
nip44Decrypt(_conversationKey, ciphertext);
}
/// Creates author-private kind-30300 reminders on the relay.
class ReminderService {
final SignedEventRelay _signedEventRelay;
final ReminderCrypto _crypto;
ReminderService({
required SignedEventRelay signedEventRelay,
required ReminderCrypto crypto,
}) : _signedEventRelay = signedEventRelay,
_crypto = crypto;
/// Encrypt, sign, and publish a new pending reminder. [notBefore] is a Unix
/// timestamp in seconds after which clients surface the reminder.
Future<void> createReminder({
required ReminderTarget target,
required int notBefore,
String? note,
}) async {
final plaintext = buildReminderPlaintext(target: target, note: note);
final ciphertext = _crypto.encrypt(plaintext);
await _signedEventRelay.submit(
kind: EventKind.eventReminder,
content: ciphertext,
tags: buildReminderTags(dTag: randomReminderDTag(), notBefore: notBefore),
);
}
}
/// Provides a [ReminderService], or null when no signing identity is
/// available (signed out).
final reminderServiceProvider = Provider<ReminderService?>((ref) {
final relayConfig = ref.watch(relayConfigProvider);
final pubkey = ref.watch(myPubkeyProvider);
final nsec = relayConfig.nsec?.trim();
if (nsec == null || nsec.isEmpty || pubkey == null || pubkey.isEmpty) {
return null;
}
final ReminderCrypto crypto;
try {
crypto = ReminderCrypto(nsec, pubkey);
} catch (_) {
return null;
}
return ReminderService(
signedEventRelay: SignedEventRelay(
session: ref.read(relaySessionProvider.notifier),
nsec: nsec,
),
crypto: crypto,
);
});
@@ -0,0 +1,91 @@
/// Reminder time presets shared by the "Remind me later" sheet.
///
/// Mirrors `desktop/src/features/reminders/lib/timePresets.ts` — same labels,
/// same "always strictly in the future" guarantee.
library;
class ReminderTimePreset {
final ReminderTimePresetId id;
final String label;
final int Function() getTimestamp;
const ReminderTimePreset({
this.id = ReminderTimePresetId.legacy,
required this.label,
required this.getTimestamp,
});
}
/// Stable identifiers let the sheet localize labels without putting UI
/// strings into the reminder scheduling model.
enum ReminderTimePresetId {
legacy,
in30Minutes,
in1Hour,
in3Hours,
tomorrow9am,
nextMonday9am,
}
int _nowSeconds() => DateTime.now().millisecondsSinceEpoch ~/ 1000;
/// Next occurrence of [dayOffset] days from now at 9am local time. If that
/// instant is already past, roll to the following day so the result is
/// always in the future.
int nextDayAt9am(int dayOffset, {DateTime? now}) {
final current = now ?? DateTime.now();
var target = DateTime(
current.year,
current.month,
current.day + dayOffset,
9,
);
if (!target.isAfter(current)) {
target = DateTime(target.year, target.month, target.day + 1, 9);
}
return target.millisecondsSinceEpoch ~/ 1000;
}
/// Days until next Monday (1-7), matching the desktop preset semantics.
int daysUntilNextMonday(DateTime now) {
final days = (DateTime.monday - now.weekday) % 7;
return days == 0 ? 7 : days;
}
final List<ReminderTimePreset> reminderTimePresets = [
ReminderTimePreset(
id: ReminderTimePresetId.in30Minutes,
label: 'In 30 minutes',
getTimestamp: () => _nowSeconds() + 30 * 60,
),
ReminderTimePreset(
id: ReminderTimePresetId.in1Hour,
label: 'In 1 hour',
getTimestamp: () => _nowSeconds() + 60 * 60,
),
ReminderTimePreset(
id: ReminderTimePresetId.in3Hours,
label: 'In 3 hours',
getTimestamp: () => _nowSeconds() + 3 * 60 * 60,
),
ReminderTimePreset(
id: ReminderTimePresetId.tomorrow9am,
label: 'Tomorrow at 9am',
getTimestamp: () => nextDayAt9am(1),
),
ReminderTimePreset(
id: ReminderTimePresetId.nextMonday9am,
label: 'Next Monday at 9am',
getTimestamp: () => nextDayAt9am(daysUntilNextMonday(DateTime.now())),
),
];
/// Combine a picked date and time into a future Unix timestamp (seconds), or
/// null when the instant is not strictly in the future — the shared guard for
/// the custom picker so a past time never fires immediately.
int? combineCustomDateTime(DateTime date, int hour, int minute) {
final target = DateTime(date.year, date.month, date.day, hour, minute);
final timestamp = target.millisecondsSinceEpoch ~/ 1000;
if (timestamp <= _nowSeconds()) return null;
return timestamp;
}
+107
View File
@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import 'package:highlight/highlight.dart' show highlight, Node;
const highlightLightTheme = <String, TextStyle>{
'keyword': TextStyle(color: Color(0xFFa626a4)),
'built_in': TextStyle(color: Color(0xFF0184bc)),
'type': TextStyle(color: Color(0xFF0184bc)),
'literal': TextStyle(color: Color(0xFF0184bc)),
'number': TextStyle(color: Color(0xFF986801)),
'string': TextStyle(color: Color(0xFF50a14f)),
'symbol': TextStyle(color: Color(0xFF50a14f)),
'comment': TextStyle(color: Color(0xFFa0a1a7), fontStyle: FontStyle.italic),
'doctag': TextStyle(color: Color(0xFFa0a1a7), fontStyle: FontStyle.italic),
'meta': TextStyle(color: Color(0xFF986801)),
'attr': TextStyle(color: Color(0xFF986801)),
'attribute': TextStyle(color: Color(0xFF986801)),
'title': TextStyle(color: Color(0xFF4078f2)),
'title.class_': TextStyle(color: Color(0xFFc18401)),
'title.function_': TextStyle(color: Color(0xFF4078f2)),
'name': TextStyle(color: Color(0xFFe45649)),
'tag': TextStyle(color: Color(0xFFe45649)),
'selector-tag': TextStyle(color: Color(0xFFe45649)),
'params': TextStyle(color: Color(0xFF383a42)),
'variable': TextStyle(color: Color(0xFFe45649)),
'subst': TextStyle(color: Color(0xFFe45649)),
'section': TextStyle(color: Color(0xFF4078f2)),
'bullet': TextStyle(color: Color(0xFF4078f2)),
'link': TextStyle(color: Color(0xFF4078f2)),
'addition': TextStyle(color: Color(0xFF50a14f)),
'deletion': TextStyle(color: Color(0xFFe45649)),
};
const highlightDarkTheme = <String, TextStyle>{
'keyword': TextStyle(color: Color(0xFFc678dd)),
'built_in': TextStyle(color: Color(0xFF56b6c2)),
'type': TextStyle(color: Color(0xFF56b6c2)),
'literal': TextStyle(color: Color(0xFF56b6c2)),
'number': TextStyle(color: Color(0xFFd19a66)),
'string': TextStyle(color: Color(0xFF98c379)),
'symbol': TextStyle(color: Color(0xFF98c379)),
'comment': TextStyle(color: Color(0xFF5c6370), fontStyle: FontStyle.italic),
'doctag': TextStyle(color: Color(0xFF5c6370), fontStyle: FontStyle.italic),
'meta': TextStyle(color: Color(0xFFd19a66)),
'attr': TextStyle(color: Color(0xFFd19a66)),
'attribute': TextStyle(color: Color(0xFFd19a66)),
'title': TextStyle(color: Color(0xFF61afef)),
'title.class_': TextStyle(color: Color(0xFFe5c07b)),
'title.function_': TextStyle(color: Color(0xFF61afef)),
'name': TextStyle(color: Color(0xFFe06c75)),
'tag': TextStyle(color: Color(0xFFe06c75)),
'selector-tag': TextStyle(color: Color(0xFFe06c75)),
'params': TextStyle(color: Color(0xFFabb2bf)),
'variable': TextStyle(color: Color(0xFFe06c75)),
'subst': TextStyle(color: Color(0xFFe06c75)),
'section': TextStyle(color: Color(0xFF61afef)),
'bullet': TextStyle(color: Color(0xFF61afef)),
'link': TextStyle(color: Color(0xFF61afef)),
'addition': TextStyle(color: Color(0xFF98c379)),
'deletion': TextStyle(color: Color(0xFFe06c75)),
};
List<InlineSpan> highlightCode(
String code,
String language,
Map<String, TextStyle> theme,
TextStyle baseStyle,
) {
try {
if (language.isEmpty) return [TextSpan(text: code, style: baseStyle)];
final result = highlight.parse(code, language: language);
if (result.nodes == null) return [TextSpan(text: code, style: baseStyle)];
return buildSpans(result.nodes!, theme, baseStyle);
} catch (_) {
return [TextSpan(text: code, style: baseStyle)];
}
}
List<InlineSpan> buildSpans(
List<Node> nodes,
Map<String, TextStyle> theme,
TextStyle baseStyle, {
int maxDepth = 10,
}) {
final spans = <InlineSpan>[];
for (final node in nodes) {
if (maxDepth <= 0) {
if (node.value != null) {
spans.add(TextSpan(text: node.value, style: baseStyle));
}
continue;
}
if (node.children != null && node.children!.isNotEmpty) {
final childStyle = node.className != null
? baseStyle.merge(theme[node.className])
: baseStyle;
spans.addAll(
buildSpans(node.children!, theme, childStyle, maxDepth: maxDepth - 1),
);
} else if (node.value != null) {
final style = node.className != null
? baseStyle.merge(theme[node.className])
: baseStyle;
spans.add(TextSpan(text: node.value, style: style));
}
}
return spans;
}
+121
View File
@@ -0,0 +1,121 @@
import 'package:flutter/material.dart';
/// Accent colors matching the desktop Buzz app.
class AccentColor {
final String name;
final Color light;
final Color dark;
final bool useThemeForegroundInDark;
final String wireValue;
const AccentColor({
required this.name,
required this.light,
required this.dark,
this.useThemeForegroundInDark = false,
required this.wireValue,
});
}
const accentColors = [
AccentColor(
name: 'Neutral',
light: Color(0xFF000000),
dark: Color(0xFFE1E4E8),
wireValue: 'neutral',
useThemeForegroundInDark: true,
),
AccentColor(
name: 'Blue',
light: Color(0xFF3B82F6),
dark: Color(0xFF60A5FA),
wireValue: '#3b82f6',
),
AccentColor(
name: 'Cyan',
light: Color(0xFF06B6D4),
dark: Color(0xFF22D3EE),
wireValue: '#06b6d4',
),
AccentColor(
name: 'Green',
light: Color(0xFF22C55E),
dark: Color(0xFF4ADE80),
wireValue: '#22c55e',
),
AccentColor(
name: 'Orange',
light: Color(0xFFF97316),
dark: Color(0xFFFB923C),
wireValue: '#f97316',
),
AccentColor(
name: 'Red',
light: Color(0xFFEF4444),
dark: Color(0xFFF87171),
wireValue: '#ef4444',
),
AccentColor(
name: 'Pink',
light: Color(0xFFEC4899),
dark: Color(0xFFF472B6),
wireValue: '#ec4899',
),
AccentColor(
name: 'Lilac',
light: Color(0xFFC0A2F1),
dark: Color(0xFFC0A2F1),
wireValue: '#c0a2f1',
),
AccentColor(
name: 'Purple',
light: Color(0xFFA855F7),
dark: Color(0xFFC084FC),
wireValue: '#a855f7',
),
AccentColor(
name: 'Indigo',
light: Color(0xFF6366F1),
dark: Color(0xFF818CF8),
wireValue: '#6366f1',
),
];
Color accentColorForScheme(ColorScheme scheme, int accentIndex) {
if (accentIndex < 0 || accentIndex >= accentColors.length) {
return scheme.primary;
}
final accent = accentColors[accentIndex];
if (scheme.brightness == Brightness.dark && accent.useThemeForegroundInDark) {
return scheme.onSurface;
}
return scheme.brightness == Brightness.light ? accent.light : accent.dark;
}
/// New default: Black.
///
/// Keep this at the end of [accentColors] so existing saved accent indexes keep
/// pointing at the same colors.
const neutralAccentIndex = 0;
const defaultAccentIndex = neutralAccentIndex;
/// Legacy default: Catppuccin Mauve/the base theme primary.
const legacyDefaultAccentIndex = -1;
int? accentIndexForWireValue(String value) {
final index = accentColors.indexWhere((accent) => accent.wireValue == value);
return index < 0 ? null : index;
}
String legacyAccentWireValue(int? index) {
// Legacy mobile indexes 0...7 match desktop Blue...Indigo except Lilac,
// which did not exist. Black (8) has no desktop wire value, so migrate it
// deterministically to Neutral rather than publishing a mobile-only value.
if (index == 8) return 'neutral';
if (index != null && index >= 0 && index <= 5) {
return accentColors[index + 1].wireValue;
}
if (index == 6) return '#a855f7';
if (index == 7) return '#6366f1';
return '#3b82f6';
}
+181
View File
@@ -0,0 +1,181 @@
// Adaptive Theme Engine
//
// Derives a Material 3 [ColorScheme] from a syntax theme's key colors
// (bg, fg, comment, git). Detects light vs dark from background luminance
// and adjusts accordingly.
//
// Ported from desktop/src/shared/theme/adaptive-theme.ts.
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'color_scheme.dart' show contrastForeground;
import 'theme_catalog.dart';
double _luminance(Color c) => c.computeLuminance();
int _to255(double v) => (v * 255.0).round().clamp(0, 255);
Color _mix(Color c1, Color c2, double factor) {
final r = c1.r + (c2.r - c1.r) * factor;
final g = c1.g + (c2.g - c1.g) * factor;
final b = c1.b + (c2.b - c1.b) * factor;
return Color.fromARGB(255, _to255(r), _to255(g), _to255(b));
}
Color _adjust(Color c, double amount) {
final target = amount > 0 ? const Color(0xFFFFFFFF) : const Color(0xFF000000);
return _mix(c, target, amount.abs());
}
const _contrastValue = 0.035;
const _contrastOffset = 0.0135;
double _calculateLumDiff(double bgLum) {
return _contrastValue * math.log(1 + (bgLum + _contrastOffset) * 10);
}
Color _findColorWithLuminance(Color base, double targetLum) {
final baseLum = _luminance(base);
if ((baseLum - targetLum).abs() < 0.001) return base;
final target = targetLum < baseLum
? const Color(0xFF000000)
: const Color(0xFFFFFFFF);
var lo = 0.0;
var hi = 1.0;
for (var i = 0; i < 20; i++) {
final mid = (lo + hi) / 2;
final testLum = _luminance(_mix(base, target, mid));
final diff = testLum - targetLum;
if (diff.abs() < 0.001) break;
if (target == const Color(0xFF000000)) {
if (testLum > targetLum) {
lo = mid;
} else {
hi = mid;
}
} else {
if (testLum < targetLum) {
lo = mid;
} else {
hi = mid;
}
}
}
return _mix(base, target, (lo + hi) / 2);
}
({Color chrome, Color primary}) _calculateChromeColors(Color syntaxBg) {
final bgLum = _luminance(syntaxBg);
final lumDiff = _calculateLumDiff(bgLum);
final targetChromeLum = bgLum - lumDiff;
if (targetChromeLum >= 0) {
return (
chrome: _findColorWithLuminance(syntaxBg, targetChromeLum),
primary: syntaxBg,
);
}
return (
chrome: _findColorWithLuminance(syntaxBg, 0),
primary: _findColorWithLuminance(syntaxBg, lumDiff),
);
}
/// Generate a full Material 3 [ColorScheme] from syntax theme colors.
///
/// Maps the desktop's CSS variable system to Material 3 semantic slots:
/// --background → surface
/// --foreground → onSurface
/// --muted → surfaceContainerHighest
/// --muted-fg → onSurfaceVariant
/// --border → outline
/// --popover → surfaceContainerHigh (elevated surfaces)
/// --destructive → error
/// --sidebar-bg → surfaceContainerLowest (chrome)
ColorScheme generateColorScheme(ThemeColors theme) {
final isDark = theme.isDark;
final syntaxBg = theme.bg;
final syntaxFg = theme.fg;
final syntaxComment = theme.comment;
final (:chrome, :primary) = _calculateChromeColors(syntaxBg);
final dir = isDark ? 1.0 : -1.0;
Color elevate(double amount) => _adjust(primary, dir * amount);
// Fallback git/accent colors
final fallbackGreen = isDark
? const Color(0xFF3FB950)
: const Color(0xFF1A7F37);
final fallbackRed = isDark
? const Color(0xFFF85149)
: const Color(0xFFCF222E);
final accentGreen = theme.added ?? fallbackGreen;
final accentRed = theme.deleted ?? fallbackRed;
// Derived surfaces
final borderColor = _mix(primary, syntaxFg, isDark ? 0.15 : 0.12);
final hoverBg = elevate(0.06);
final popoverBg = elevate(0.08);
return ColorScheme(
brightness: isDark ? Brightness.dark : Brightness.light,
// Primary — use the theme fg as a muted "primary" to keep the theme
// feeling cohesive. Accent color override happens in applyAccent().
primary: syntaxFg,
onPrimary: contrastForeground(syntaxFg),
primaryContainer: hoverBg,
onPrimaryContainer: syntaxFg,
// Secondary
secondary: syntaxComment,
onSecondary: contrastForeground(syntaxComment),
secondaryContainer: hoverBg,
onSecondaryContainer: syntaxFg,
// Tertiary
tertiary: accentGreen,
onTertiary: contrastForeground(accentGreen),
tertiaryContainer: hoverBg,
onTertiaryContainer: accentGreen,
// Error / destructive
error: accentRed,
onError: contrastForeground(accentRed),
errorContainer: _mix(primary, accentRed, 0.15),
onErrorContainer: accentRed,
// Surfaces
surface: primary,
onSurface: syntaxFg,
onSurfaceVariant: syntaxComment,
// Outline / borders
outline: borderColor,
outlineVariant: _mix(primary, borderColor, 0.5),
// Inverse
inverseSurface: syntaxFg,
onInverseSurface: primary,
inversePrimary: syntaxComment,
// Shadow / scrim
shadow: const Color(0xFF000000),
scrim: const Color(0xFF000000),
surfaceTint: syntaxFg,
// Container hierarchy
surfaceContainerLowest: chrome,
surfaceContainerLow: _mix(chrome, primary, 0.5),
surfaceContainer: primary,
surfaceContainerHigh: popoverBg,
surfaceContainerHighest: elevate(0.04),
);
}
+49
View File
@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
@immutable
class AppColors extends ThemeExtension<AppColors> {
final Color success;
final Color warning;
final Color accent;
/// Gradient for the app's top section, non-null only under the Buzz themes.
/// Carried on the theme rather than read from a provider so any surface can
/// opt in via `context.appColors.topSectionGradient` — see
/// `buzzTopSectionGradient`.
final Gradient? topSectionGradient;
const AppColors({
required this.success,
required this.warning,
required this.accent,
this.topSectionGradient,
});
@override
AppColors copyWith({
Color? success,
Color? warning,
Color? accent,
Gradient? topSectionGradient,
}) => AppColors(
success: success ?? this.success,
warning: warning ?? this.warning,
accent: accent ?? this.accent,
topSectionGradient: topSectionGradient ?? this.topSectionGradient,
);
@override
AppColors lerp(ThemeExtension<AppColors>? other, double t) {
if (other is! AppColors) return this;
return AppColors(
success: Color.lerp(success, other.success, t)!,
warning: Color.lerp(warning, other.warning, t)!,
accent: Color.lerp(accent, other.accent, t)!,
topSectionGradient: Gradient.lerp(
topSectionGradient,
other.topSectionGradient,
t,
),
);
}
}
+339
View File
@@ -0,0 +1,339 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'app_colors.dart';
import 'color_scheme.dart';
import 'grid.dart';
import 'text_theme.dart';
/// Border radius constants matching desktop shadcn "New York" style.
/// Desktop uses --radius: 0.625rem (10px) as base:
/// lg = 10px, md = 8px, sm = 6px
class Radii {
/// Small radius for compact UI elements.
static const double xs = 4.0;
static const double lg = 10.0;
static const double md = 8.0;
static const double sm = 6.0;
static const double card = 12.0; // grouped settings cards
static const double popover = 20.0;
static const double dialog = 24.0; // desktop uses rounded-3xl for dialogs
/// Fully rounds pills, circles, and other capsule shapes.
static const double full = 999.0;
}
class AppTheme {
static ThemeData light({
ColorScheme? colorScheme,
Gradient? topSectionGradient,
}) {
final scheme = colorScheme ?? lightColorScheme;
final appColors = AppColors(
success: const Color(0xFF40A02B), // Catppuccin Latte Green — universal
warning: const Color(0xFFDF8E1D), // Latte Yellow
accent: scheme.tertiary,
topSectionGradient: topSectionGradient,
);
return _buildTheme(
scheme: scheme,
appColors: appColors,
brightness: Brightness.light,
statusBarIconBrightness: Brightness.dark,
statusBarBrightness: Brightness.light,
);
}
static ThemeData dark({
ColorScheme? colorScheme,
Gradient? topSectionGradient,
}) {
final scheme = colorScheme ?? darkColorScheme;
final appColors = AppColors(
success: const Color(
0xFFA6DA95,
), // Catppuccin Macchiato Green — universal
warning: const Color(0xFFEED49F), // Macchiato Yellow
accent: scheme.tertiary,
topSectionGradient: topSectionGradient,
);
return _buildTheme(
scheme: scheme,
appColors: appColors,
brightness: Brightness.dark,
statusBarIconBrightness: Brightness.light,
statusBarBrightness: Brightness.dark,
);
}
static ThemeData _buildTheme({
required ColorScheme scheme,
required AppColors appColors,
required Brightness brightness,
required Brightness statusBarIconBrightness,
required Brightness statusBarBrightness,
}) {
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
splashFactory: NoSplash.splashFactory,
scaffoldBackgroundColor: scheme.surface,
extensions: [appColors],
fontFamily: 'Inter',
textTheme: textTheme,
appBarTheme: AppBarTheme(
backgroundColor: Colors.transparent,
foregroundColor: scheme.onSurface,
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
titleTextStyle: textTheme.titleMedium?.copyWith(
color: scheme.onSurface,
),
systemOverlayStyle: SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: statusBarIconBrightness,
statusBarBrightness: statusBarBrightness,
),
),
// Bottom navigation: clean style, no indicator pill
navigationBarTheme: NavigationBarThemeData(
backgroundColor: scheme.surface,
elevation: 0,
indicatorColor: Colors.transparent,
iconTheme: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return IconThemeData(color: scheme.primary, size: 24);
}
return IconThemeData(color: scheme.onSurfaceVariant, size: 24);
}),
labelTextStyle: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return textTheme.labelSmall?.copyWith(
color: scheme.primary,
fontWeight: FontWeight.w600,
);
}
return textTheme.labelSmall?.copyWith(color: scheme.onSurfaceVariant);
}),
),
// Buttons: desktop uses rounded-md (8px), h-9 (36px), px-4 (16px)
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: scheme.primary,
foregroundColor: scheme.onPrimary,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
minimumSize: const Size(0, 36),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.md),
),
textStyle: textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
minimumSize: const Size(0, 36),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.md),
),
textStyle: textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
backgroundColor: scheme.surface,
foregroundColor: scheme.onSurface,
side: BorderSide(color: scheme.outline, width: 1),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
minimumSize: const Size(0, 36),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.md),
),
textStyle: textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: scheme.onSurface,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
minimumSize: const Size(0, 36),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.md),
),
textStyle: textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
),
// Cards: desktop uses rounded-lg (10px), flat, no elevation
cardTheme: CardThemeData(
color: scheme.surfaceContainerHighest,
margin: EdgeInsets.zero,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.lg),
),
),
// Inputs: desktop uses outlined style, rounded-md (8px), h-9 (36px)
inputDecorationTheme: InputDecorationTheme(
filled: false,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(Radii.md),
borderSide: BorderSide(color: scheme.outline),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(Radii.md),
borderSide: BorderSide(color: scheme.outline),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(Radii.md),
borderSide: BorderSide(color: scheme.primary),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(Radii.md),
borderSide: BorderSide(color: scheme.error),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(Radii.md),
borderSide: BorderSide(color: scheme.error),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
isDense: true,
),
// Dialogs: desktop uses rounded-3xl (24px), custom overlay
dialogTheme: DialogThemeData(
backgroundColor: scheme.surface,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.dialog),
side: BorderSide(color: scheme.outline),
),
titleTextStyle: textTheme.titleLarge?.copyWith(
color: scheme.onSurface,
fontSize: 18,
fontWeight: FontWeight.w600,
letterSpacing: -0.3,
),
contentTextStyle: textTheme.bodyMedium?.copyWith(
color: scheme.onSurfaceVariant,
),
),
progressIndicatorTheme: ProgressIndicatorThemeData(
strokeWidth: 2,
color: scheme.primary,
circularTrackColor: scheme.onSurfaceVariant.withValues(alpha: 0.2),
),
listTileTheme: ListTileThemeData(
titleTextStyle: textTheme.titleSmall?.copyWith(color: scheme.onSurface),
subtitleTextStyle: textTheme.bodyMedium?.copyWith(
color: scheme.secondary,
),
iconColor: scheme.secondary,
contentPadding: const EdgeInsets.symmetric(horizontal: Grid.twelve),
minVerticalPadding: Grid.twelve,
horizontalTitleGap: Grid.twelve,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.md),
),
),
// Chips: desktop uses rounded-sm (6px)
chipTheme: ChipThemeData(
labelStyle: textTheme.bodySmall?.copyWith(color: scheme.secondary),
// M3 resolves the chip container via `color` (WidgetStateProperty);
// `selectedColor` is the legacy M2 path and is ignored here. Selected
// filter chips (Pulse/Search/Activity tabs) use the accent.
color: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) return scheme.primary;
return scheme.surfaceContainerHighest;
}),
checkmarkColor: scheme.onPrimary,
shape: RoundedRectangleBorder(
side: BorderSide.none,
borderRadius: BorderRadius.circular(Radii.sm),
),
side: BorderSide.none,
padding: const EdgeInsets.symmetric(horizontal: 8),
labelPadding: EdgeInsets.zero,
),
// Popups/menus share the elevated 20px mobile popover treatment.
popupMenuTheme: PopupMenuThemeData(
color: scheme.surface.withValues(alpha: 0.98),
elevation: 8,
shadowColor: scheme.shadow.withValues(alpha: 0.18),
surfaceTintColor: Colors.transparent,
textStyle: textTheme.labelLarge?.copyWith(color: scheme.onSurface),
labelTextStyle: WidgetStatePropertyAll(
textTheme.labelLarge?.copyWith(color: scheme.onSurface),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.popover),
side: BorderSide(
color: Colors.black.withValues(alpha: 0.04),
width: 1,
),
),
),
// Bottom sheet: match dialog radius
bottomSheetTheme: BottomSheetThemeData(
backgroundColor: scheme.surface,
elevation: 0,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(Radii.dialog),
),
),
),
// Tooltips: desktop uses rounded-md, primary bg
tooltipTheme: TooltipThemeData(
decoration: BoxDecoration(
color: scheme.primary,
borderRadius: BorderRadius.circular(Radii.md),
),
textStyle: textTheme.bodySmall?.copyWith(
color: scheme.onPrimary,
fontSize: 12,
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
),
dividerTheme: DividerThemeData(
color: scheme.outline,
thickness: 1,
space: 1,
),
// Snackbar
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.md),
),
),
);
}
}
+103
View File
@@ -0,0 +1,103 @@
import 'package:flutter/material.dart';
import 'accent_colors.dart';
import 'app_colors.dart';
/// Name of the first-party Buzz theme. Buzz reuses the GitHub Light palette for
/// every base color; the one thing that sets it apart is a branded gradient
/// painted across the app's top section. Mirrors desktop, where the same
/// gradient fills the sidebar canvas — see `data-buzz-sidebar` in
/// `desktop/src/shared/styles/globals/theme.css`.
const buzzThemeName = 'buzz';
/// Name of the dark counterpart, which reuses the GitHub Dark palette and the
/// dark-tuned gradient stops. Paired with [buzzThemeName] in `themePairs`, so
/// the two behave as a single "Buzz" choice under System mode.
const buzzDarkThemeName = 'buzz-dark';
/// Whether [themeName] is either half of the Buzz pair. Both halves enable the
/// gradient so System mode keeps it on across an OS light/dark switch.
bool isBuzzTheme(String themeName) =>
themeName == buzzThemeName || themeName == buzzDarkThemeName;
/// Whether the current widget tree is using the first-party Buzz treatment.
bool isBuzzThemeContext(BuildContext context) =>
Theme.of(context).extension<AppColors>()?.topSectionGradient != null;
/// Primary foreground for the mobile top navigation.
///
/// Every theme uses its own [ColorScheme.onSurface]. Buzz is the exception:
/// its desktop-matching top gradient needs a neutral black or white foreground
/// rather than the accent-derived color scheme foreground.
Color navigationPrimaryForeground(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
if (!isBuzzThemeContext(context)) return scheme.onSurface;
return scheme.brightness == Brightness.dark ? Colors.white : Colors.black;
}
/// Secondary label and placeholder foreground for the mobile top navigation.
Color navigationSecondaryForeground(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
if (!isBuzzThemeContext(context)) return scheme.onSurfaceVariant;
return navigationPrimaryForeground(context).withValues(alpha: 0.4);
}
/// Channel-section label and icon foreground for the mobile side navigation.
///
/// Section labels need more hierarchy than a placeholder. Buzz therefore uses
/// a stronger neutral over its gradient, while all other themes preserve their
/// established secondary foreground token.
Color navigationSectionForeground(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
if (!isBuzzThemeContext(context)) return scheme.onSurfaceVariant;
return navigationPrimaryForeground(context).withValues(alpha: 0.8);
}
/// Search-field surface for the mobile top navigation.
Color navigationSearchSurface(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
if (!isBuzzThemeContext(context)) return scheme.surfaceContainerHighest;
return navigationPrimaryForeground(context).withValues(alpha: 0.04);
}
/// A low-contrast navigation divider derived from the active theme foreground.
Color navigationDivider(BuildContext context, double opacity) =>
navigationPrimaryForeground(context).withValues(alpha: opacity);
/// Buzz renders with its fixed neutral foreground while preserving the stored
/// wire accent so the user's choice returns on another theme.
int effectiveAccentIndex(String themeName, String storedAccent) {
if (isBuzzTheme(themeName)) return neutralAccentIndex;
return accentIndexForWireValue(storedAccent) ?? defaultAccentIndex;
}
/// Gradient stops, matching desktop's `--buzz-gradient-*` custom properties.
const _lightTop = Color(0xFFE6E6B6);
const _lightBottom = Color(0xFFC4D0DA);
const _darkTop = Color(0xFF4A4616);
const _darkBottom = Color(0xFF0A1423);
/// The Buzz gradient for the app's top section, or null when [themeName] is not
/// a Buzz theme — in which case the section keeps its default frosted fill.
///
/// The stops are fully opaque: under Buzz the color replaces the frosted
/// treatment rather than tinting it, matching desktop's solid sidebar canvas.
///
/// [brightness] comes from the applied color scheme rather than the theme name,
/// so System mode picks the right stops as the OS switches.
LinearGradient? buzzTopSectionGradient(
String themeName,
Brightness brightness,
) {
if (!isBuzzTheme(themeName)) return null;
final isDark = brightness == Brightness.dark;
return LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
isDark ? _darkTop : _lightTop,
isDark ? _darkBottom : _lightBottom,
],
);
}
+94
View File
@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'accent_colors.dart';
// Catppuccin Latte (mauve accent) — matches Buzz desktop light theme
const lightColorScheme = ColorScheme(
brightness: Brightness.light,
primary: Color(0xFF8839EF), // Latte Mauve
onPrimary: Color(0xFFEFF1F5), // Latte Base
primaryContainer: Color(0xFFE6E9EF), // Latte Mantle
onPrimaryContainer: Color(0xFF4C4F69), // Latte Text
secondary: Color(0xFF6C6F85), // Latte Subtext0
onSecondary: Color(0xFFFFFFFF),
secondaryContainer: Color(0xFFCCD0DA), // Latte Surface1
onSecondaryContainer: Color(0xFF4C4F69), // Latte Text
tertiary: Color(0xFF1E66F5), // Latte Blue
onTertiary: Color(0xFFFFFFFF),
tertiaryContainer: Color(0xFFBCC0CC), // Latte Surface2
onTertiaryContainer: Color(0xFF1E66F5), // Latte Blue
error: Color(0xFFD20F39), // Latte Red
onError: Color(0xFFFFFFFF),
errorContainer: Color(0xFFFDD8E0),
onErrorContainer: Color(0xFFD20F39),
surface: Color(0xFFFFFFFF),
onSurface: Color(0xFF4C4F69), // Latte Text
onSurfaceVariant: Color(0xFF6C6F85), // Latte Subtext0
outline: Color(0xFFBCC0CC), // Latte Surface2
outlineVariant: Color(0xFFCCD0DA), // Latte Surface1
inverseSurface: Color(0xFF4C4F69), // Latte Text
onInverseSurface: Color(0xFFEFF1F5), // Latte Base
inversePrimary: Color(0xFFA875F5), // Macchiato Mauve (saturated)
shadow: Color(0xFF000000),
scrim: Color(0xFF000000),
surfaceTint: Color(0xFF8839EF), // Latte Mauve
surfaceContainerHighest: Color(0xFFFFFFFF),
);
// Catppuccin Macchiato (mauve accent) — matches Buzz desktop dark theme
const darkColorScheme = ColorScheme(
brightness: Brightness.dark,
primary: Color(0xFFA875F5), // Macchiato Mauve (saturated)
onPrimary: Color(0xFF24273A), // Macchiato Base
primaryContainer: Color(0xFF363A4F), // Macchiato Surface0
onPrimaryContainer: Color(0xFFCAD3F5), // Macchiato Text
secondary: Color(0xFFA5ADCB), // Macchiato Subtext0
onSecondary: Color(0xFF24273A), // Macchiato Base
secondaryContainer: Color(0xFF494D64), // Macchiato Surface1
onSecondaryContainer: Color(0xFFCAD3F5), // Macchiato Text
tertiary: Color(0xFF8AADF4), // Macchiato Blue
onTertiary: Color(0xFF24273A), // Macchiato Base
tertiaryContainer: Color(0xFF363A4F), // Macchiato Surface0
onTertiaryContainer: Color(0xFF8AADF4), // Macchiato Blue
error: Color(0xFFED8796), // Macchiato Red
onError: Color(0xFF24273A), // Macchiato Base
errorContainer: Color(0xFF3D2030),
onErrorContainer: Color(0xFFED8796),
surface: Color(0xFF24273A), // Macchiato Base
onSurface: Color(0xFFCAD3F5), // Macchiato Text
onSurfaceVariant: Color(0xFFA5ADCB), // Macchiato Subtext0
outline: Color(0xFF494D64), // Macchiato Surface1
outlineVariant: Color(0xFF363A4F), // Macchiato Surface0
inverseSurface: Color(0xFFCAD3F5), // Macchiato Text
onInverseSurface: Color(0xFF24273A), // Macchiato Base
inversePrimary: Color(0xFF8839EF), // Latte Mauve
shadow: Color(0xFF000000),
scrim: Color(0xFF000000),
surfaceTint: Color(0xFFA875F5), // Macchiato Mauve (saturated)
surfaceContainerHighest: Color(0xFF1E2030), // Macchiato Mantle
);
/// Compute a contrast-safe foreground color for a given background.
/// Uses WCAG contrast ratio (higher ratio wins) instead of a simple luminance
/// cutoff, so colors like Blue (#3B82F6) correctly get black text (5.7:1)
/// rather than white (3.7:1).
Color contrastForeground(Color bg) {
final lum = bg.computeLuminance();
// WCAG contrast ratio: (L1 + 0.05) / (L2 + 0.05), L1 >= L2
final contrastWithBlack = (lum + 0.05) / 0.05; // black luminance = 0
final contrastWithWhite = 1.05 / (lum + 0.05); // white luminance = 1
return contrastWithBlack >= contrastWithWhite
? const Color(0xFF000000)
: const Color(0xFFFFFFFF);
}
/// Returns a [ColorScheme] with the given accent applied as primary.
ColorScheme applyAccent(ColorScheme base, int accentIndex) {
if (accentIndex < 0 || accentIndex >= accentColors.length) {
return base;
}
final color = accentColorForScheme(base, accentIndex);
final onColor = contrastForeground(color);
return base.copyWith(primary: color, onPrimary: onColor, surfaceTint: color);
}
@@ -0,0 +1,167 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'accent_colors.dart';
import 'theme_catalog.dart';
import 'theme_provider.dart' show effectiveTheme, schemeForAppearanceMode;
const communityThemeDTag = 'community-theme';
const defaultCommunityTheme = CommunityThemePreference(
theme: 'buzz',
accent: '#3b82f6',
followSystem: true,
);
class CommunityThemePreference {
final int version;
final String theme;
final String accent;
final bool followSystem;
const CommunityThemePreference({
this.version = 1,
required this.theme,
required this.accent,
required this.followSystem,
});
factory CommunityThemePreference.fromJson(Map<String, dynamic> json) {
if (json['version'] != 1 ||
json['theme'] is! String ||
findTheme(json['theme'] as String) == null ||
json['accent'] is! String ||
accentIndexForWireValue(json['accent'] as String) == null ||
json['followSystem'] is! bool) {
throw const FormatException('Invalid community theme preference');
}
return CommunityThemePreference(
theme: json['theme'] as String,
accent: json['accent'] as String,
followSystem: json['followSystem'] as bool,
);
}
Map<String, dynamic> toJson() => {
'version': version,
'theme': theme,
'accent': accent,
'followSystem': followSystem,
};
ThemeMode get mode {
if (followSystem) return ThemeMode.system;
return findTheme(theme)?.isDark == true ? ThemeMode.dark : ThemeMode.light;
}
@override
bool operator ==(Object other) =>
other is CommunityThemePreference &&
theme == other.theme &&
accent == other.accent &&
followSystem == other.followSystem;
@override
int get hashCode => Object.hash(theme, accent, followSystem);
}
class CommunityThemeStorage {
static const _prefix = 'buzz-community-theme.v1';
static const _outboxPrefix = 'buzz-community-theme-outbox.v1';
static const _migrationPrefix = 'buzz-community-theme-migrated.v1';
static const _legacyModeKey = 'buzz_theme_mode';
static const _legacyAccentKey = 'buzz_accent_color';
static const _legacySchemeKey = 'buzz_color_scheme';
final SharedPreferences prefs;
const CommunityThemeStorage(this.prefs);
String key(String pubkey, String relayUrl) =>
'$_prefix:$pubkey:${Uri.encodeComponent(normalizeCommunityRelayUrl(relayUrl))}';
String outboxKey(String pubkey, String relayUrl) =>
'$_outboxPrefix:$pubkey:${Uri.encodeComponent(normalizeCommunityRelayUrl(relayUrl))}';
CommunityThemePreference? _readKey(String storageKey) {
try {
final raw = prefs.getString(storageKey);
if (raw == null) return null;
final decoded = jsonDecode(raw);
if (decoded is! Map<String, dynamic>) return null;
return CommunityThemePreference.fromJson(decoded);
} catch (_) {
return null;
}
}
CommunityThemePreference? read(String pubkey, String relayUrl) =>
_readKey(key(pubkey, relayUrl));
CommunityThemePreference? readOutbox(String pubkey, String relayUrl) =>
_readKey(outboxKey(pubkey, relayUrl));
Future<bool> write(
String pubkey,
String relayUrl,
CommunityThemePreference preference,
) => prefs.setString(key(pubkey, relayUrl), jsonEncode(preference.toJson()));
Future<bool> writeOutbox(
String pubkey,
String relayUrl,
CommunityThemePreference preference,
) => prefs.setString(
outboxKey(pubkey, relayUrl),
jsonEncode(preference.toJson()),
);
Future<void> clearOutbox(
String pubkey,
String relayUrl,
CommunityThemePreference acknowledged,
) async {
if (readOutbox(pubkey, relayUrl) == acknowledged) {
await prefs.remove(outboxKey(pubkey, relayUrl));
}
}
bool hasMigrated(String pubkey) =>
prefs.getBool('$_migrationPrefix:$pubkey') == true;
Future<bool> markMigrated(String pubkey) =>
prefs.setBool('$_migrationPrefix:$pubkey', true);
Future<void> writeLegacy(CommunityThemePreference preference) async {
await prefs.setString(_legacyModeKey, preference.mode.name);
await prefs.setString(_legacySchemeKey, preference.theme);
await prefs.setInt(
_legacyAccentKey,
accentIndexForWireValue(preference.accent) ?? defaultAccentIndex,
);
}
CommunityThemePreference legacyPreference() {
final modeName = prefs.getString(_legacyModeKey);
final mode =
ThemeMode.values.where((value) => value.name == modeName).firstOrNull ??
ThemeMode.system;
final storedTheme = prefs.getString(_legacySchemeKey);
final theme = findTheme(storedTheme ?? 'buzz')?.name ?? 'buzz';
final legacyAccent = prefs.getInt(_legacyAccentKey);
final resolvedTheme = switch (mode) {
ThemeMode.system => schemeForAppearanceMode(theme, mode) ?? theme,
ThemeMode.light ||
ThemeMode.dark => effectiveTheme(theme, mode)?.name ?? theme,
};
return CommunityThemePreference(
theme: resolvedTheme,
accent: legacyAccentWireValue(legacyAccent),
followSystem: mode == ThemeMode.system,
);
}
}
String normalizeCommunityRelayUrl(String relayUrl) =>
relayUrl.trim().replaceFirst(RegExp(r'/+$'), '').toLowerCase();
@@ -0,0 +1,230 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import '../crypto/nip44.dart';
import '../relay/relay.dart';
import 'accent_colors.dart';
import 'community_theme_preference.dart';
import 'community_theme_sync.dart';
import 'theme_provider.dart';
class CommunityThemeNotifier extends Notifier<CommunityThemePreference> {
CommunityThemeSyncManager? _manager;
late CommunityThemeStorage _storage;
String? _pubkey;
String? _relayUrl;
Future<void> _persistenceQueue = Future<void>.value();
int _localRevision = 0;
CommunityThemePreference? _scopedLocalPreference;
String? _scopedLocalPubkey;
String? _scopedLocalRelayUrl;
@override
CommunityThemePreference build() {
_manager?.dispose();
_manager = null;
_storage = ref.watch(communityThemeStorageProvider);
final config = ref.watch(relayConfigProvider);
final session = ref.watch(relaySessionProvider);
final pubkey = pubkeyFromNsec(config.nsec);
_pubkey = pubkey;
_relayUrl = config.baseUrl;
if (pubkey == null || config.nsec == null) {
final legacy = _storage.legacyPreference();
unawaited(_storage.writeLegacy(legacy));
return legacy;
}
final cached = _storage.read(pubkey, config.baseUrl);
final dirty = _storage.readOutbox(pubkey, config.baseUrl);
final inMemoryLocal =
_scopedLocalPubkey == pubkey && _scopedLocalRelayUrl == config.baseUrl
? _scopedLocalPreference
: null;
if (inMemoryLocal == null) {
_scopedLocalPreference = null;
_scopedLocalPubkey = pubkey;
_scopedLocalRelayUrl = config.baseUrl;
}
final fallback = _storage.hasMigrated(pubkey)
? defaultCommunityTheme
: _storage.legacyPreference();
final initial = inMemoryLocal ?? dirty ?? cached ?? fallback;
if (session.status == SessionStatus.connected) {
late final CommunityThemeSyncManager manager;
manager = CommunityThemeSyncManager(
pubkey: pubkey,
relaySession: ref.read(relaySessionProvider.notifier),
signedEventRelay: SignedEventRelay(
session: ref.read(relaySessionProvider.notifier),
nsec: config.nsec!,
),
crypto: _crypto(config.nsec!, pubkey),
onRemote: (remote) => _applyRemote(manager, remote),
onPublished: (preference) {
if (_scopedLocalPubkey == pubkey &&
_scopedLocalRelayUrl == config.baseUrl &&
_scopedLocalPreference == preference) {
_scopedLocalPreference = null;
}
unawaited(_storage.clearOutbox(pubkey, config.baseUrl, preference));
},
);
_manager = manager;
final pending = inMemoryLocal ?? dirty;
if (pending != null) manager.publish(pending);
Future.microtask(() async {
final result = await manager.initialize();
if (_manager != manager) return;
if (result.status == CommunityThemeRemoteStatus.absent) {
final seedRevision = _localRevision;
await _enqueuePersistence(() async {
if (_manager != manager || _localRevision != seedRevision) return;
final currentDirty = _storage.readOutbox(pubkey, config.baseUrl);
final seed =
currentDirty ?? _storage.read(pubkey, config.baseUrl) ?? state;
if (!await _storage.write(pubkey, config.baseUrl, seed)) return;
if (_manager != manager || _localRevision != seedRevision) return;
if (!await _storage.writeOutbox(pubkey, config.baseUrl, seed)) {
return;
}
if (_manager == manager && _localRevision == seedRevision) {
manager.publish(seed);
}
});
}
if (result.status == CommunityThemeRemoteStatus.valid ||
result.status == CommunityThemeRemoteStatus.absent) {
await _storage.markMigrated(pubkey);
}
});
ref.onDispose(manager.dispose);
}
return initial;
}
void setMode(ThemeMode mode) {
var theme = state.theme;
if (mode == ThemeMode.system) {
theme = schemeForAppearanceMode(theme, mode) ?? theme;
} else {
final effective = effectiveTheme(theme, mode);
if (effective != null) theme = effective.name;
}
_save(
CommunityThemePreference(
theme: theme,
accent: state.accent,
followSystem: mode == ThemeMode.system,
),
);
}
void setTheme(String? theme) {
_save(
CommunityThemePreference(
theme: theme ?? defaultSchemeName,
accent: state.accent,
followSystem: state.followSystem,
),
);
}
void setAccent(int index) {
if (index < 0 || index >= accentColors.length) return;
_save(
CommunityThemePreference(
theme: state.theme,
accent: accentColors[index].wireValue,
followSystem: state.followSystem,
),
);
}
void _save(CommunityThemePreference preference) {
if (preference == state) return;
state = preference;
_localRevision++;
final pubkey = _pubkey;
final relayUrl = _relayUrl;
if (pubkey == null || relayUrl == null) {
unawaited(_storage.writeLegacy(preference));
return;
}
_scopedLocalPreference = preference;
_scopedLocalPubkey = pubkey;
_scopedLocalRelayUrl = relayUrl;
_manager?.stage(preference);
unawaited(
_enqueuePersistence(
() => _persistAndPublish(pubkey, relayUrl, preference),
),
);
}
Future<void> _enqueuePersistence(Future<void> Function() operation) {
final result = _persistenceQueue.then((_) => operation());
_persistenceQueue = result.catchError((Object _) {});
return result;
}
Future<void> _persistAndPublish(
String pubkey,
String relayUrl,
CommunityThemePreference preference,
) async {
if (!await _storage.write(pubkey, relayUrl, preference)) return;
if (!await _storage.writeOutbox(pubkey, relayUrl, preference)) return;
if (_pubkey == pubkey && _relayUrl == relayUrl) {
final manager = _manager;
if (manager != null) {
manager.stage(preference);
manager.publishStaged(preference);
}
}
}
void _applyRemote(
CommunityThemeSyncManager manager,
RemoteCommunityTheme remote,
) {
if (_manager != manager) return;
final pubkey = _pubkey;
final relayUrl = _relayUrl;
if (pubkey != null &&
relayUrl != null &&
_storage.readOutbox(pubkey, relayUrl) != null) {
final dirty = _storage.readOutbox(pubkey, relayUrl)!;
manager.publish(dirty);
return;
}
state = remote.preference;
if (pubkey != null && relayUrl != null) {
unawaited(_storage.write(pubkey, relayUrl, remote.preference));
}
}
}
CommunityThemeCrypto _crypto(String nsec, String pubkey) {
final privateHex = nostr.Nip19.decode(payload: nsec).data;
final key = getConversationKey(privateHex, pubkey);
return CommunityThemeCrypto(
encrypt: (plaintext) => nip44Encrypt(key, plaintext),
decrypt: (ciphertext) => nip44Decrypt(key, ciphertext),
);
}
final communityThemeStorageProvider = Provider<CommunityThemeStorage>(
(ref) => CommunityThemeStorage(ref.watch(savedPrefsProvider)),
);
final communityThemeProvider =
NotifierProvider<CommunityThemeNotifier, CommunityThemePreference>(
CommunityThemeNotifier.new,
);
@@ -0,0 +1,366 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import '../relay/relay.dart';
import 'community_theme_preference.dart';
class CommunityThemeCrypto {
final String Function(String) encrypt;
final String Function(String) decrypt;
const CommunityThemeCrypto({required this.encrypt, required this.decrypt});
}
enum CommunityThemeRemoteStatus { valid, absent, invalid, unavailable }
class RemoteCommunityTheme {
final CommunityThemePreference preference;
final int createdAt;
final String eventId;
const RemoteCommunityTheme({
required this.preference,
required this.createdAt,
required this.eventId,
});
}
class CommunityThemeRemoteResult {
final CommunityThemeRemoteStatus status;
final RemoteCommunityTheme? remote;
const CommunityThemeRemoteResult(this.status, [this.remote]);
}
class CommunityThemeSyncManager {
final String pubkey;
final RelaySessionNotifier relaySession;
final SignedEventRelay signedEventRelay;
final CommunityThemeCrypto crypto;
final Duration debounce;
final Duration publishRetryBase;
final Duration publishRetryMax;
final Duration subscriptionRetryBase;
final void Function(RemoteCommunityTheme) onRemote;
final void Function(CommunityThemePreference) onPublished;
Timer? _publishTimer;
Timer? _subscriptionRetryTimer;
void Function()? _unsubscribe;
CommunityThemePreference? _pending;
CommunityThemePreference? _lastPublished;
int _lastCreatedAt = 0;
String _lastEventId = '';
RemoteCommunityTheme? _lastRemote;
int _subscriptionEpoch = 0;
int _subscriptionRetryAttempt = 0;
int _publishRetryAttempt = 0;
bool _publishInFlight = false;
bool _publishRequestedWhileInFlight = false;
bool _disposed = false;
CommunityThemeSyncManager({
required this.pubkey,
required this.relaySession,
required this.signedEventRelay,
required this.crypto,
required this.onRemote,
this.onPublished = _ignorePublished,
this.debounce = const Duration(seconds: 2),
this.publishRetryBase = const Duration(seconds: 1),
this.publishRetryMax = const Duration(seconds: 30),
this.subscriptionRetryBase = const Duration(seconds: 1),
});
CommunityThemePreference? get pending => _pending;
Future<CommunityThemeRemoteResult> fetchRemote() async {
try {
final events = await relaySession.fetchHistory(_themeFilter(limit: 1));
if (events.isEmpty) {
return const CommunityThemeRemoteResult(
CommunityThemeRemoteStatus.absent,
);
}
final event = events.reduce(_newerEvent);
final remote = _decode(event);
return remote == null
? const CommunityThemeRemoteResult(CommunityThemeRemoteStatus.invalid)
: CommunityThemeRemoteResult(
CommunityThemeRemoteStatus.valid,
remote,
);
} catch (_) {
return const CommunityThemeRemoteResult(
CommunityThemeRemoteStatus.unavailable,
);
}
}
Future<CommunityThemeRemoteResult> initialize() async {
final subscribed = await _startLiveSubscription();
if (_disposed) {
return const CommunityThemeRemoteResult(
CommunityThemeRemoteStatus.unavailable,
);
}
final result = await fetchRemote();
if (_disposed) return result;
if (result.status == CommunityThemeRemoteStatus.valid) {
_accept(result.remote!);
}
final remote = _lastRemote;
if (remote != null) {
return CommunityThemeRemoteResult(
CommunityThemeRemoteStatus.valid,
remote,
);
}
if (!subscribed && result.status == CommunityThemeRemoteStatus.absent) {
return const CommunityThemeRemoteResult(
CommunityThemeRemoteStatus.unavailable,
);
}
return result;
}
Future<bool> _startLiveSubscription() async {
if (_disposed) return false;
final epoch = ++_subscriptionEpoch;
try {
final unsubscribe = await relaySession.subscribe(
_themeFilter(limit: 0),
(event) {
if (_disposed || epoch != _subscriptionEpoch) return;
final remote = _decode(event);
if (remote != null) _accept(remote);
},
onClosed: (message) => _handleSubscriptionClosed(epoch, message),
);
if (_disposed || epoch != _subscriptionEpoch) {
unsubscribe();
return false;
}
_unsubscribe = unsubscribe;
_subscriptionRetryAttempt = 0;
return true;
} catch (error) {
if (!_disposed && epoch == _subscriptionEpoch) {
debugPrint('[CommunityThemeSync] live subscription failed: $error');
_scheduleSubscriptionRetry();
}
return false;
}
}
void _handleSubscriptionClosed(int epoch, String message) {
if (_disposed || epoch != _subscriptionEpoch) return;
debugPrint('[CommunityThemeSync] live subscription closed: $message');
_unsubscribe = null;
_scheduleSubscriptionRetry();
}
void _scheduleSubscriptionRetry() {
if (_disposed || _subscriptionRetryTimer != null) return;
final multiplier = 1 << min(_subscriptionRetryAttempt, 5);
_subscriptionRetryAttempt++;
_subscriptionRetryTimer = Timer(subscriptionRetryBase * multiplier, () {
_subscriptionRetryTimer = null;
unawaited(_recoverLiveSubscription());
});
}
Future<void> _recoverLiveSubscription() async {
if (_disposed) return;
if (!await _startLiveSubscription()) return;
// A relay CLOSED removes the retained subscription from RelaySession, so
// reconnect replay cannot recover it. Query the replacement coordinate
// after re-subscribing to close the gap while this stream was silent.
final result = await fetchRemote();
if (_disposed) return;
if (result.status == CommunityThemeRemoteStatus.valid) {
_accept(result.remote!);
}
}
NostrFilter _themeFilter({required int limit}) => NostrFilter(
kinds: const [EventKind.readState],
authors: [pubkey],
tags: const {
'#d': [communityThemeDTag],
},
limit: limit,
);
void stage(CommunityThemePreference preference) {
if (_disposed) return;
_pending = preference;
_publishRetryAttempt = 0;
_publishTimer?.cancel();
_publishTimer = null;
}
void publish(CommunityThemePreference preference) {
stage(preference);
publishStaged(preference);
}
void publishStaged(CommunityThemePreference preference) {
if (_disposed || _pending != preference) return;
_schedulePublish(debounce);
}
void _schedulePublish(Duration delay) {
if (_disposed) return;
_publishTimer?.cancel();
_publishTimer = Timer(delay, () {
_publishTimer = null;
unawaited(flush());
});
}
void cancelPending() {
_publishTimer?.cancel();
_publishTimer = null;
_pending = null;
}
Future<void> flush() async {
if (_publishInFlight) {
_publishTimer?.cancel();
_publishTimer = null;
_publishRequestedWhileInFlight = true;
return;
}
final preference = _pending;
if (_disposed || preference == null) return;
if (preference == _lastPublished) {
_pending = null;
onPublished(preference);
return;
}
_publishInFlight = true;
try {
final content = crypto.encrypt(jsonEncode(preference.toJson()));
if (_disposed) return;
final createdAt = max(
DateTime.now().millisecondsSinceEpoch ~/ 1000,
_lastCreatedAt + 1,
);
NostrEvent? signed;
await signedEventRelay.submit(
kind: EventKind.readState,
content: content,
tags: const [
['d', communityThemeDTag],
['t', communityThemeDTag],
],
createdAt: createdAt,
onSigned: (event) => signed = event,
);
if (_disposed) return;
final published = signed;
if (published == null) {
throw StateError('Signed event coordinate unavailable');
}
final publishedCoordinateIsStale =
_lastCreatedAt > published.createdAt ||
(_lastCreatedAt == published.createdAt &&
_lastEventId.isNotEmpty &&
_lastEventId.compareTo(published.id) < 0);
if (publishedCoordinateIsStale) {
_lastPublished = null;
_publishRetryAttempt = 0;
if (_pending == preference) _schedulePublish(Duration.zero);
return;
}
_lastCreatedAt = published.createdAt;
_lastEventId = published.id;
_lastPublished = preference;
_publishRetryAttempt = 0;
if (_pending == preference) _pending = null;
onPublished(preference);
} catch (error) {
debugPrint('[CommunityThemeSync] publish failed: $error');
if (_disposed || _pending != preference) return;
final multiplier = 1 << min(_publishRetryAttempt, 30);
_publishRetryAttempt++;
final retryMs = min(
publishRetryBase.inMilliseconds * multiplier,
publishRetryMax.inMilliseconds,
);
_schedulePublish(Duration(milliseconds: retryMs));
} finally {
_publishInFlight = false;
if (!_disposed &&
_pending != null &&
(_publishRequestedWhileInFlight || _pending != preference) &&
_publishTimer == null) {
_publishRequestedWhileInFlight = false;
_schedulePublish(Duration.zero);
} else {
_publishRequestedWhileInFlight = false;
}
}
}
RemoteCommunityTheme? _decode(NostrEvent event) {
if (event.pubkey != pubkey ||
event.getTagValue('d') != communityThemeDTag) {
return null;
}
try {
final decoded = jsonDecode(crypto.decrypt(event.content));
if (decoded is! Map<String, dynamic>) return null;
return RemoteCommunityTheme(
preference: CommunityThemePreference.fromJson(decoded),
createdAt: event.createdAt,
eventId: event.id,
);
} catch (_) {
return null;
}
}
void _accept(RemoteCommunityTheme remote) {
if (remote.createdAt < _lastCreatedAt ||
(remote.createdAt == _lastCreatedAt &&
_lastEventId.isNotEmpty &&
remote.eventId.compareTo(_lastEventId) >= 0)) {
return;
}
_lastCreatedAt = remote.createdAt;
_lastEventId = remote.eventId;
_lastRemote = remote;
if (_pending != null) {
_lastPublished = null;
return;
}
_lastPublished = null;
onRemote(remote);
}
void dispose() {
if (_disposed) return;
_disposed = true;
_subscriptionEpoch++;
_subscriptionRetryTimer?.cancel();
_subscriptionRetryTimer = null;
cancelPending();
_unsubscribe?.call();
_unsubscribe = null;
}
}
void _ignorePublished(CommunityThemePreference _) {}
NostrEvent _newerEvent(NostrEvent left, NostrEvent right) {
if (right.createdAt != left.createdAt) {
return right.createdAt > left.createdAt ? right : left;
}
return right.id.compareTo(left.id) < 0 ? right : left;
}
+37
View File
@@ -0,0 +1,37 @@
class Grid {
/// Quarter spacing - 2 pixels
static const double quarter = 2.0;
/// Half spacing - 4 pixels
static const double half = 4.0;
/// Extra extra small spacing - 8 pixels
static const double xxs = 8.0;
/// Twelve spacing - 12 pixels
static const double twelve = 12.0;
/// Extra small spacing - 16 pixels
static const double xs = 16.0;
/// Standard app content gutter - 20 pixels
static const double gutter = 20.0;
/// Small spacing - 24 pixels
static const double sm = 24.0;
/// Medium spacing - 32 pixels
static const double md = 32.0;
/// Large spacing - 40 pixels
static const double lg = 40.0;
/// Extra large spacing - 48 pixels
static const double xl = 48.0;
/// Extra extra large spacing - 64 pixels
static const double xxl = 64.0;
/// Extra extra extra large spacing - 80 pixels
static const double xxxl = 80.0;
}
@@ -0,0 +1,137 @@
import 'package:flutter/material.dart';
import 'grid.dart';
const _fontFamily = 'Inter';
/// Avatar size for full channel and thread messages.
const messageAvatarSize = 42.0;
/// Avatar size for conversation-oriented Activity rows.
const activityAvatarSize = messageAvatarSize;
/// Avatar size for compact message-result rows.
const compactMessageAvatarSize = messageAvatarSize;
/// Horizontal space between a message avatar and its content.
const messageAvatarContentGap = Grid.twelve;
/// Primary message copy: 15sp regular on a 20sp line height.
const messageBodyTextStyle = TextStyle(
fontFamily: _fontFamily,
fontSize: 15,
fontWeight: FontWeight.w400,
height: 20 / 15,
letterSpacing: 0,
);
/// Message author names: 15sp semibold on a 17sp line height.
const messageUsernameTextStyle = TextStyle(
fontFamily: _fontFamily,
fontSize: 15,
fontWeight: FontWeight.w600,
height: 17 / 15,
letterSpacing: 0,
);
/// Secondary author metadata: 15sp regular on a tight 17sp line height.
const messageMetadataTextStyle = TextStyle(
fontFamily: _fontFamily,
fontSize: 15,
fontWeight: FontWeight.w400,
height: 17 / 15,
letterSpacing: 0,
);
/// Message timestamps share the secondary author metadata style.
const messageTimestampTextStyle = messageMetadataTextStyle;
/// Compact reply previews: 13.1sp regular on a 17sp line height.
const replyPreviewTextStyle = TextStyle(
fontFamily: _fontFamily,
fontSize: 13.1,
fontWeight: FontWeight.w400,
height: 17 / 13.1,
letterSpacing: 0,
);
/// Reaction counts: 13.1sp medium on a 17sp line height.
const reactionCountTextStyle = TextStyle(
fontFamily: _fontFamily,
fontSize: 13.1,
fontWeight: FontWeight.w500,
height: 17 / 13.1,
letterSpacing: 0,
);
/// Channel and thread titles: 20sp bold on a 24sp line height.
const channelTitleTextStyle = TextStyle(
fontFamily: _fontFamily,
fontSize: 20,
fontWeight: FontWeight.w700,
height: 24 / 20,
letterSpacing: 0,
);
/// Primary labels in compact content lists.
const contentListTitleTextStyle = messageUsernameTextStyle;
/// Secondary copy in compact content lists.
const contentListBodyTextStyle = TextStyle(
fontFamily: _fontFamily,
fontSize: 13.1,
fontWeight: FontWeight.w400,
height: 17 / 13.1,
letterSpacing: 0,
);
/// Timestamps in compact content lists.
const contentListTimestampTextStyle = messageMetadataTextStyle;
/// Filter chip labels use a tighter 15sp Inter treatment.
const filterChipTextStyle = TextStyle(
fontFamily: _fontFamily,
fontSize: 15,
fontWeight: FontWeight.w400,
height: 1,
letterSpacing: 0,
);
/// Search fields use the primary 15sp body treatment.
const searchInputTextStyle = messageBodyTextStyle;
/// System message actor names: 15sp semibold on a 17sp line height.
const systemMessageHeadingTextStyle = TextStyle(
fontFamily: _fontFamily,
fontSize: 15,
fontWeight: FontWeight.w600,
height: 17 / 15,
letterSpacing: 0,
);
/// System message copy: 15sp regular on a 20sp line height.
const systemMessageBodyTextStyle = TextStyle(
fontFamily: _fontFamily,
fontSize: 15,
fontWeight: FontWeight.w400,
height: 20 / 15,
letterSpacing: 0,
);
/// Activity sender names share the primary author style.
const activityUsernameTextStyle = messageUsernameTextStyle;
/// Activity timestamps share the secondary author metadata style.
const activityTimestampTextStyle = messageMetadataTextStyle;
/// Activity context labels: 13.1sp medium on a 17sp line height.
const activityContextTextStyle = TextStyle(
fontFamily: _fontFamily,
fontSize: 13.1,
fontWeight: FontWeight.w500,
height: 17 / 13.1,
letterSpacing: 0,
);
/// Activity message previews use the primary message copy style.
const activityPreviewTextStyle = messageBodyTextStyle;
+121
View File
@@ -0,0 +1,121 @@
import 'package:flutter/material.dart';
const _fontFamily = 'Inter';
const _chatLineHeight = 22 / 16;
/// Optional 12sp body style for compact secondary metadata.
const bodyExtraSmallTextStyle = TextStyle(
fontFamily: _fontFamily,
fontSize: 12,
fontWeight: FontWeight.w400,
height: 1.25,
letterSpacing: 0,
);
const textTheme = TextTheme(
displayLarge: TextStyle(
fontFamily: _fontFamily,
fontSize: 52,
fontWeight: FontWeight.w400,
height: 1.23,
letterSpacing: 0,
),
displayMedium: TextStyle(
fontFamily: _fontFamily,
fontSize: 44,
fontWeight: FontWeight.w400,
height: 1.18,
letterSpacing: 0,
),
displaySmall: TextStyle(
fontFamily: _fontFamily,
fontSize: 36,
fontWeight: FontWeight.w400,
height: 1.22,
letterSpacing: 0,
),
headlineLarge: TextStyle(
fontFamily: _fontFamily,
fontSize: 32,
fontWeight: FontWeight.w600,
height: 1.25,
letterSpacing: 0,
),
headlineMedium: TextStyle(
fontFamily: _fontFamily,
fontSize: 28,
fontWeight: FontWeight.w600,
height: 1.29,
letterSpacing: 0,
),
headlineSmall: TextStyle(
fontFamily: _fontFamily,
fontSize: 24,
fontWeight: FontWeight.w600,
height: 1.33,
letterSpacing: 0,
),
titleLarge: TextStyle(
fontFamily: _fontFamily,
fontSize: 24,
fontWeight: FontWeight.w400,
height: 1.25,
letterSpacing: 0,
),
titleMedium: TextStyle(
fontFamily: _fontFamily,
fontSize: 20,
fontWeight: FontWeight.w500,
height: 1.3,
letterSpacing: 0,
),
titleSmall: TextStyle(
fontFamily: _fontFamily,
fontSize: 16,
fontWeight: FontWeight.w500,
height: _chatLineHeight,
letterSpacing: 0,
),
labelLarge: TextStyle(
fontFamily: _fontFamily,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.2,
letterSpacing: 0,
),
labelMedium: TextStyle(
fontFamily: _fontFamily,
fontSize: 14,
fontWeight: FontWeight.w500,
height: 1.25,
letterSpacing: 0,
),
labelSmall: TextStyle(
fontFamily: _fontFamily,
fontSize: 11,
fontWeight: FontWeight.w500,
height: 1.2,
letterSpacing: 0,
),
bodyLarge: TextStyle(
fontFamily: _fontFamily,
fontSize: 16,
fontWeight: FontWeight.w400,
height: 20 / 16,
letterSpacing: 0,
),
bodyMedium: TextStyle(
fontFamily: _fontFamily,
fontSize: 14,
fontWeight: FontWeight.w400,
height: 1.3,
letterSpacing: 0,
),
bodySmall: TextStyle(
fontFamily: _fontFamily,
fontSize: 14,
fontWeight: FontWeight.w400,
height: 1.25,
letterSpacing: 0,
),
);
+16
View File
@@ -0,0 +1,16 @@
export 'accent_colors.dart';
export 'adaptive_theme.dart';
export 'app_colors.dart';
export 'app_theme.dart';
export 'buzz_theme.dart';
export 'color_scheme.dart';
export 'community_theme_preference.dart';
export 'community_theme_provider.dart';
export 'community_theme_sync.dart';
export 'grid.dart';
export 'message_typography.dart';
export 'theme_catalog.dart';
export 'theme_extensions.dart';
export 'theme_pairs.dart';
export 'theme_provider.dart';
export 'text_theme.dart' show bodyExtraSmallTextStyle;
+548
View File
@@ -0,0 +1,548 @@
// Static catalog of all 60 Shiki syntax theme color definitions.
//
// Each entry contains the key colors extracted from the Shiki theme JSON:
// bg, fg, comment (+ optional git added/deleted colors).
// The adaptive theme engine uses these to derive a full Material ColorScheme.
import 'package:flutter/material.dart';
class ThemeColors {
final String name;
final Color bg;
final Color fg;
final Color comment;
final Color? added;
final Color? deleted;
const ThemeColors({
required this.name,
required this.bg,
required this.fg,
required this.comment,
this.added,
this.deleted,
});
bool get isDark => bg.computeLuminance() < 0.5;
/// Human-readable display name: 'catppuccin-mocha' → 'Catppuccin Mocha'.
String get displayName => name
.split('-')
.map((w) => w.isNotEmpty ? '${w[0].toUpperCase()}${w.substring(1)}' : w)
.join(' ');
}
/// Known light theme names — used to show sun/moon icons before loading.
const lightThemeNames = <String>{
'buzz',
'catppuccin-latte',
'everforest-light',
'github-light',
'github-light-default',
'github-light-high-contrast',
'gruvbox-light-hard',
'gruvbox-light-medium',
'gruvbox-light-soft',
'kanagawa-lotus',
'light-plus',
'material-theme-lighter',
'min-light',
'one-light',
'rose-pine-dawn',
'slack-ochin',
'snazzy-light',
'solarized-light',
'vitesse-light',
};
/// All available color schemes, sorted alphabetically.
const themeCatalog = <ThemeColors>[
ThemeColors(
name: 'andromeeda',
bg: Color(0xFF23262E),
fg: Color(0xFFD5CED9),
comment: Color(0xFFA0A1A7),
added: Color(0xFF9BC53D),
deleted: Color(0xFFFC644D),
),
ThemeColors(
name: 'aurora-x',
bg: Color(0xFF07090F),
fg: Color(0xFFD4D4D4),
comment: Color(0xFF546E7A),
added: Color(0xFF64D389),
deleted: Color(0xFFDD5074),
),
ThemeColors(
name: 'ayu-dark',
bg: Color(0xFF10141C),
fg: Color(0xFFBFBDB6),
comment: Color(0xFF5A6673),
added: Color(0xFF70BF56),
deleted: Color(0xFFF26D78),
),
// Buzz and Buzz Dark are first-party: they borrow the GitHub Light / GitHub
// Dark palettes wholesale and are distinguished only by the branded gradient
// painted across the app's top section (see buzz_theme.dart).
ThemeColors(
name: 'buzz',
bg: Color(0xFFFFFFFF),
fg: Color(0xFF24292E),
comment: Color(0xFF6A737D),
added: Color(0xFF28A745),
deleted: Color(0xFFD73A49),
),
ThemeColors(
name: 'buzz-dark',
bg: Color(0xFF24292E),
fg: Color(0xFFE1E4E8),
comment: Color(0xFF6A737D),
added: Color(0xFF34D058),
deleted: Color(0xFFEA4A5A),
),
ThemeColors(
name: 'catppuccin-frappe',
bg: Color(0xFF303446),
fg: Color(0xFFC6D0F5),
comment: Color(0xFF949CBB),
added: Color(0xFFA6D189),
deleted: Color(0xFFE78284),
),
ThemeColors(
name: 'catppuccin-latte',
bg: Color(0xFFEFF1F5),
fg: Color(0xFF4C4F69),
comment: Color(0xFF7C7F93),
added: Color(0xFF40A02B),
deleted: Color(0xFFD20F39),
),
ThemeColors(
name: 'catppuccin-macchiato',
bg: Color(0xFF24273A),
fg: Color(0xFFCAD3F5),
comment: Color(0xFF939AB7),
added: Color(0xFFA6DA95),
deleted: Color(0xFFED8796),
),
ThemeColors(
name: 'catppuccin-mocha',
bg: Color(0xFF1E1E2E),
fg: Color(0xFFCDD6F4),
comment: Color(0xFF9399B2),
added: Color(0xFFA6E3A1),
deleted: Color(0xFFF38BA8),
),
ThemeColors(
name: 'dark-plus',
bg: Color(0xFF1E1E1E),
fg: Color(0xFFD4D4D4),
comment: Color(0xFF6A9955),
),
ThemeColors(
name: 'dracula',
bg: Color(0xFF282A36),
fg: Color(0xFFF8F8F2),
comment: Color(0xFF6272A4),
added: Color(0xFF50FA7B),
deleted: Color(0xFFFF5555),
),
ThemeColors(
name: 'dracula-soft',
bg: Color(0xFF282A36),
fg: Color(0xFFF6F6F4),
comment: Color(0xFF7B7F8B),
added: Color(0xFF50FA7B),
deleted: Color(0xFFEE6666),
),
ThemeColors(
name: 'everforest-dark',
bg: Color(0xFF2D353B),
fg: Color(0xFFD3C6AA),
comment: Color(0xFFD3C6AA),
added: Color(0xFFA7C080),
deleted: Color(0xFFE67E80),
),
ThemeColors(
name: 'everforest-light',
bg: Color(0xFFFDF6E3),
fg: Color(0xFF5C6A72),
comment: Color(0xFF5C6A72),
added: Color(0xFF8DA101),
deleted: Color(0xFFF85552),
),
ThemeColors(
name: 'github-dark',
bg: Color(0xFF24292E),
fg: Color(0xFFE1E4E8),
comment: Color(0xFF6A737D),
added: Color(0xFF34D058),
deleted: Color(0xFFEA4A5A),
),
ThemeColors(
name: 'github-dark-default',
bg: Color(0xFF0D1117),
fg: Color(0xFFE6EDF3),
comment: Color(0xFF8B949E),
added: Color(0xFF3FB950),
deleted: Color(0xFFF85149),
),
ThemeColors(
name: 'github-dark-dimmed',
bg: Color(0xFF22272E),
fg: Color(0xFFADBac7),
comment: Color(0xFF768390),
added: Color(0xFF57AB5A),
deleted: Color(0xFFE5534B),
),
ThemeColors(
name: 'github-dark-high-contrast',
bg: Color(0xFF0A0C10),
fg: Color(0xFFF0F3F6),
comment: Color(0xFFBDC4CC),
added: Color(0xFF26CD4D),
deleted: Color(0xFFFF6A69),
),
ThemeColors(
name: 'github-light',
bg: Color(0xFFFFFFFF),
fg: Color(0xFF24292E),
comment: Color(0xFF6A737D),
added: Color(0xFF28A745),
deleted: Color(0xFFD73A49),
),
ThemeColors(
name: 'github-light-default',
bg: Color(0xFFFFFFFF),
fg: Color(0xFF1F2328),
comment: Color(0xFF6E7781),
added: Color(0xFF1A7F37),
deleted: Color(0xFFCF222E),
),
ThemeColors(
name: 'github-light-high-contrast',
bg: Color(0xFFFFFFFF),
fg: Color(0xFF0E1116),
comment: Color(0xFF66707B),
added: Color(0xFF055D20),
deleted: Color(0xFFA0111F),
),
ThemeColors(
name: 'gruvbox-dark-hard',
bg: Color(0xFF1D2021),
fg: Color(0xFFEBDBB2),
comment: Color(0xFF928374),
added: Color(0xFFEBDBB2),
deleted: Color(0xFFCC241D),
),
ThemeColors(
name: 'gruvbox-dark-medium',
bg: Color(0xFF282828),
fg: Color(0xFFEBDBB2),
comment: Color(0xFF928374),
added: Color(0xFFEBDBB2),
deleted: Color(0xFFCC241D),
),
ThemeColors(
name: 'gruvbox-dark-soft',
bg: Color(0xFF32302F),
fg: Color(0xFFEBDBB2),
comment: Color(0xFF928374),
added: Color(0xFFEBDBB2),
deleted: Color(0xFFCC241D),
),
ThemeColors(
name: 'gruvbox-light-hard',
bg: Color(0xFFF9F5D7),
fg: Color(0xFF3C3836),
comment: Color(0xFF928374),
added: Color(0xFF3C3836),
deleted: Color(0xFFCC241D),
),
ThemeColors(
name: 'gruvbox-light-medium',
bg: Color(0xFFFBF1C7),
fg: Color(0xFF3C3836),
comment: Color(0xFF928374),
added: Color(0xFF3C3836),
deleted: Color(0xFFCC241D),
),
ThemeColors(
name: 'gruvbox-light-soft',
bg: Color(0xFFF2E5BC),
fg: Color(0xFF3C3836),
comment: Color(0xFF928374),
added: Color(0xFF3C3836),
deleted: Color(0xFFCC241D),
),
ThemeColors(
name: 'houston',
bg: Color(0xFF17191E),
fg: Color(0xFFEEF0F9),
comment: Color(0xFFEEF0F9),
added: Color(0xFF4BF3C8),
deleted: Color(0xFFF4587E),
),
ThemeColors(
name: 'kanagawa-dragon',
bg: Color(0xFF181616),
fg: Color(0xFFC5C9C5),
comment: Color(0xFF737C73),
added: Color(0xFF76946A),
deleted: Color(0xFFC34043),
),
ThemeColors(
name: 'kanagawa-lotus',
bg: Color(0xFFF2ECBC),
fg: Color(0xFF545464),
comment: Color(0xFF716E61),
added: Color(0xFF6E915F),
deleted: Color(0xFFD7474B),
),
ThemeColors(
name: 'kanagawa-wave',
bg: Color(0xFF1F1F28),
fg: Color(0xFFDCD7BA),
comment: Color(0xFF727169),
added: Color(0xFF76946A),
deleted: Color(0xFFC34043),
),
ThemeColors(
name: 'laserwave',
bg: Color(0xFF27212E),
fg: Color(0xFFFFFFFF),
comment: Color(0xFF91889B),
added: Color(0xFF74DFC4),
deleted: Color(0xFFB381C5),
),
ThemeColors(
name: 'light-plus',
bg: Color(0xFFFFFFFF),
fg: Color(0xFF000000),
comment: Color(0xFF008000),
),
ThemeColors(
name: 'material-theme',
bg: Color(0xFF263238),
fg: Color(0xFFEEFFFF),
comment: Color(0xFF546E7A),
added: Color(0xFFC3E88D),
deleted: Color(0xFFF07178),
),
ThemeColors(
name: 'material-theme-darker',
bg: Color(0xFF212121),
fg: Color(0xFFEEFFFF),
comment: Color(0xFF545454),
added: Color(0xFFC3E88D),
deleted: Color(0xFFF07178),
),
ThemeColors(
name: 'material-theme-lighter',
bg: Color(0xFFFAFAFA),
fg: Color(0xFF90A4AE),
comment: Color(0xFF90A4AE),
added: Color(0xFF91B859),
deleted: Color(0xFFE53935),
),
ThemeColors(
name: 'material-theme-ocean',
bg: Color(0xFF0F111A),
fg: Color(0xFFBABED8),
comment: Color(0xFF464B5D),
added: Color(0xFFC3E88D),
deleted: Color(0xFFF07178),
),
ThemeColors(
name: 'material-theme-palenight',
bg: Color(0xFF292D3E),
fg: Color(0xFFBABED8),
comment: Color(0xFF676E95),
added: Color(0xFFC3E88D),
deleted: Color(0xFFF07178),
),
ThemeColors(
name: 'min-dark',
bg: Color(0xFF1F1F1F),
fg: Color(0xFFD4D4D4),
comment: Color(0xFF6B737C),
),
ThemeColors(
name: 'min-light',
bg: Color(0xFFFFFFFF),
fg: Color(0xFF212121),
comment: Color(0xFFC2C3C5),
),
ThemeColors(
name: 'monokai',
bg: Color(0xFF272822),
fg: Color(0xFFF8F8F2),
comment: Color(0xFF88846F),
),
ThemeColors(
name: 'night-owl',
bg: Color(0xFF011627),
fg: Color(0xFFD6DEEB),
comment: Color(0xFF637777),
added: Color(0xFF9CCC65),
deleted: Color(0xFFEF5350),
),
ThemeColors(
name: 'nord',
bg: Color(0xFF2E3440),
fg: Color(0xFFD8DEE9),
comment: Color(0xFF616E88),
added: Color(0xFFA3BE8C),
deleted: Color(0xFFBF616A),
),
ThemeColors(
name: 'one-dark-pro',
bg: Color(0xFF282C34),
fg: Color(0xFFABB2BF),
comment: Color(0xFFABB2BF),
added: Color(0xFF109868),
deleted: Color(0xFF9A353D),
),
ThemeColors(
name: 'one-light',
bg: Color(0xFFFAFAFA),
fg: Color(0xFF383A42),
comment: Color(0xFFA0A1A7),
),
ThemeColors(
name: 'plastic',
bg: Color(0xFF21252B),
fg: Color(0xFFA9B2C3),
comment: Color(0xFF5F6672),
added: Color(0xFF98C379),
deleted: Color(0xFFE06C75),
),
ThemeColors(
name: 'poimandres',
bg: Color(0xFF1B1E28),
fg: Color(0xFFA6ACCD),
comment: Color(0xFF767C9D),
added: Color(0xFF5FB3A1),
deleted: Color(0xFFD0679D),
),
ThemeColors(
name: 'red',
bg: Color(0xFF390000),
fg: Color(0xFFF8F8F8),
comment: Color(0xFFE7C0C0),
),
ThemeColors(
name: 'rose-pine',
bg: Color(0xFF191724),
fg: Color(0xFFE0DEF4),
comment: Color(0xFF6E6A86),
added: Color(0xFF9CCFD8),
deleted: Color(0xFF908CAA),
),
ThemeColors(
name: 'rose-pine-dawn',
bg: Color(0xFFFAF4ED),
fg: Color(0xFF575279),
comment: Color(0xFF9893A5),
added: Color(0xFF56949F),
deleted: Color(0xFF797593),
),
ThemeColors(
name: 'rose-pine-moon',
bg: Color(0xFF232136),
fg: Color(0xFFE0DEF4),
comment: Color(0xFF6E6A86),
added: Color(0xFF9CCFD8),
deleted: Color(0xFF908CAA),
),
ThemeColors(
name: 'slack-dark',
bg: Color(0xFF222222),
fg: Color(0xFFE6E6E6),
comment: Color(0xFF6A9955),
added: Color(0xFFECB22E),
deleted: Color(0xFFFFFFFF),
),
ThemeColors(
name: 'slack-ochin',
bg: Color(0xFFFFFFFF),
fg: Color(0xFF000000),
comment: Color(0xFF357B42),
added: Color(0xFFECB22E),
deleted: Color(0xFFFFFFFF),
),
ThemeColors(
name: 'snazzy-light',
bg: Color(0xFFFAFBFC),
fg: Color(0xFF565869),
comment: Color(0xFFADB1C2),
added: Color(0xFF2DAE58),
deleted: Color(0xFFFF5C57),
),
ThemeColors(
name: 'solarized-dark',
bg: Color(0xFF002B36),
fg: Color(0xFF839496),
comment: Color(0xFF586E75),
),
ThemeColors(
name: 'solarized-light',
bg: Color(0xFFFDF6E3),
fg: Color(0xFF657B83),
comment: Color(0xFF93A1A1),
),
ThemeColors(
name: 'synthwave-84',
bg: Color(0xFF262335),
fg: Color(0xFFD4D4D4),
comment: Color(0xFF848BBD),
added: Color(0xFF72F1B8),
deleted: Color(0xFFFE4450),
),
ThemeColors(
name: 'tokyo-night',
bg: Color(0xFF1A1B26),
fg: Color(0xFFA9B1D6),
comment: Color(0xFF51597D),
added: Color(0xFF449DAB),
deleted: Color(0xFF914C54),
),
ThemeColors(
name: 'vesper',
bg: Color(0xFF101010),
fg: Color(0xFFFFFFFF),
comment: Color(0xFF8B8B8B),
added: Color(0xFF99FFE4),
deleted: Color(0xFFFF8080),
),
ThemeColors(
name: 'vitesse-black',
bg: Color(0xFF000000),
fg: Color(0xFFDBD7CA),
comment: Color(0xFF758575),
added: Color(0xFF4D9375),
deleted: Color(0xFFCB7676),
),
ThemeColors(
name: 'vitesse-dark',
bg: Color(0xFF121212),
fg: Color(0xFFDBD7CA),
comment: Color(0xFF758575),
added: Color(0xFF4D9375),
deleted: Color(0xFFCB7676),
),
ThemeColors(
name: 'vitesse-light',
bg: Color(0xFFFFFFFF),
fg: Color(0xFF393A34),
comment: Color(0xFFA0ADA0),
added: Color(0xFF1E754F),
deleted: Color(0xFFAB5959),
),
];
/// Lookup a theme by name. Returns null if not found.
ThemeColors? findTheme(String name) {
for (final t in themeCatalog) {
if (t.name == name) return t;
}
return null;
}
@@ -0,0 +1,15 @@
import 'package:flutter/material.dart';
import 'app_colors.dart';
extension AppThemeExtension on BuildContext {
ThemeData get theme => Theme.of(this);
ColorScheme get colors => theme.colorScheme;
TextTheme get textTheme => theme.textTheme;
AppColors get appColors {
final ext = theme.extension<AppColors>();
assert(ext != null, 'AppColors not found in ThemeData.extensions');
return ext!;
}
}
+115
View File
@@ -0,0 +1,115 @@
import 'theme_catalog.dart';
/// Light → dark theme counterparts, ported from the desktop app's `THEME_PAIRS`
/// (`desktop/src/shared/theme/theme-loader.ts`) so both clients offer the same
/// System-mode pairings.
///
/// Buzz leads the map the way it leads desktop's, so the first-party pair sorts
/// ahead of the borrowed syntax themes wherever insertion order is preserved.
const themePairs = <String, String>{
'buzz': 'buzz-dark',
'catppuccin-latte': 'catppuccin-mocha',
'everforest-light': 'everforest-dark',
'github-light': 'github-dark',
'github-light-default': 'github-dark-default',
'github-light-high-contrast': 'github-dark-high-contrast',
'gruvbox-light-hard': 'gruvbox-dark-hard',
'gruvbox-light-medium': 'gruvbox-dark-medium',
'gruvbox-light-soft': 'gruvbox-dark-soft',
'kanagawa-lotus': 'kanagawa-wave',
'light-plus': 'dark-plus',
'material-theme-lighter': 'material-theme',
'min-light': 'min-dark',
'one-light': 'one-dark-pro',
'rose-pine-dawn': 'rose-pine',
'slack-ochin': 'slack-dark',
'solarized-light': 'solarized-dark',
'vitesse-light': 'vitesse-dark',
};
final Map<String, String> _darkToLight = {
for (final entry in themePairs.entries) entry.value: entry.key,
};
/// The counterpart of [name] in the opposite brightness, or null when the theme
/// has no pair. Resolves in both directions.
String? themePairFor(String name) => themePairs[name] ?? _darkToLight[name];
/// Whether [name] participates in a light/dark pair, and so can follow the OS.
bool isPairedTheme(String name) => themePairFor(name) != null;
/// [themeCatalog] partitioned the way the appearance picker offers it.
class ThemeGroups {
/// Light members of light/dark pairs — the System-mode options. Each stands
/// in for both halves of its pair.
final List<ThemeColors> paired;
/// Every light theme, paired or not — the Light-mode options.
final List<ThemeColors> light;
/// Every dark theme, paired or not — the Dark-mode options.
final List<ThemeColors> dark;
const ThemeGroups({
required this.paired,
required this.light,
required this.dark,
});
}
ThemeGroups? _groups;
/// [themeCatalog] grouped for the appearance picker, each list sorted by
/// display name. Computed once — the catalog is a compile-time constant.
ThemeGroups themeGroups() {
final cached = _groups;
if (cached != null) return cached;
final paired = <ThemeColors>[];
final light = <ThemeColors>[];
final dark = <ThemeColors>[];
for (final theme in themeCatalog) {
if (theme.isDark) {
dark.add(theme);
continue;
}
light.add(theme);
if (themePairs.containsKey(theme.name)) paired.add(theme);
}
int byDisplayName(ThemeColors a, ThemeColors b) =>
a.displayName.compareTo(b.displayName);
paired.sort(byDisplayName);
light.sort(byDisplayName);
dark.sort(byDisplayName);
return _groups = ThemeGroups(paired: paired, light: light, dark: dark);
}
/// Tokens that only describe a theme's brightness, stripped from paired labels.
const _modeTokens = <String>{
'light',
'latte',
'dawn',
'lotus',
'ochin',
'lighter',
'plus',
};
/// Display label for a pair, derived from its light member: strips the
/// mode-specific tokens so `github-light` reads as "Github" and stands for both
/// halves. Mirrors desktop's `pairedThemeLabel`.
String pairedThemeLabel(String lightName) {
final stripped = lightName
.split('-')
.where((token) => !_modeTokens.contains(token))
.toList();
// Stripping can remove everything (e.g. "light-plus") — fall back to the raw
// name so the row is never blank.
final parts = stripped.isEmpty ? lightName.split('-') : stripped;
return parts
.map((w) => w.isEmpty ? w : '${w[0].toUpperCase()}${w.substring(1)}')
.join(' ');
}
+269
View File
@@ -0,0 +1,269 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'accent_colors.dart';
import 'adaptive_theme.dart';
import 'buzz_theme.dart';
import 'color_scheme.dart';
import 'theme_catalog.dart';
import 'theme_pairs.dart';
const _themeModeKey = 'buzz_theme_mode';
const _accentKey = 'buzz_accent_color';
const _schemeKey = 'buzz_color_scheme';
/// Buzz ships as the default: the first-party pair, so a fresh install gets the
/// branded top-section gradient without picking a theme first.
const defaultSchemeName = buzzThemeName;
const defaultSchemeDisplayName = 'Buzz';
/// Pre-loaded SharedPreferences instance, overridden in main().
final savedPrefsProvider = Provider<SharedPreferences>(
(_) => throw UnimplementedError('Must be overridden'),
);
/// Tracks the appearance mode: follow the OS, or pin light/dark.
class ThemeNotifier extends Notifier<ThemeMode> {
@override
ThemeMode build() {
final prefs = ref.read(savedPrefsProvider);
final stored = prefs.getString(_themeModeKey);
if (stored != null) {
return ThemeMode.values.where((m) => m.name == stored).firstOrNull ??
ThemeMode.system;
}
return ThemeMode.system;
}
void setMode(ThemeMode mode) {
state = mode;
ref.read(savedPrefsProvider).setString(_themeModeKey, mode.name);
}
}
final themeProvider = NotifierProvider<ThemeNotifier, ThemeMode>(
ThemeNotifier.new,
);
/// Tracks the selected accent color index.
class AccentNotifier extends Notifier<int> {
@override
int build() {
final prefs = ref.read(savedPrefsProvider);
final stored = prefs.getInt(_accentKey);
if (stored == legacyDefaultAccentIndex) {
prefs.setInt(_accentKey, defaultAccentIndex);
return defaultAccentIndex;
}
if (stored == null || stored < 0 || stored >= accentColors.length) {
return defaultAccentIndex;
}
return stored;
}
void setAccent(int index) {
state = index;
ref.read(savedPrefsProvider).setInt(_accentKey, index);
}
}
final accentProvider = NotifierProvider<AccentNotifier, int>(
AccentNotifier.new,
);
/// Tracks the selected color scheme name.
/// null means "use default" ([defaultSchemeDisplayName]).
class SchemeNotifier extends Notifier<String?> {
@override
String? build() {
final prefs = ref.read(savedPrefsProvider);
final stored = prefs.getString(_schemeKey);
final compatible = schemeForAppearanceMode(
stored,
ref.watch(themeProvider),
);
if (compatible != stored) {
if (compatible == null) {
prefs.remove(_schemeKey);
} else {
prefs.setString(_schemeKey, compatible);
}
}
return compatible;
}
void setScheme(String? name) {
state = name;
final prefs = ref.read(savedPrefsProvider);
if (name == null) {
prefs.remove(_schemeKey);
} else {
prefs.setString(_schemeKey, name);
}
}
}
final schemeProvider = NotifierProvider<SchemeNotifier, String?>(
SchemeNotifier.new,
);
/// Returns a scheme that can honor [mode] without silently pinning brightness.
///
/// System mode only offers paired themes because they have both light and dark
/// variants. Switching to it from an unpaired or unknown selection therefore
/// falls back to the first paired theme, matching the picker ordering.
String? schemeForAppearanceMode(String? schemeName, ThemeMode mode) {
if (mode != ThemeMode.system) return schemeName;
final selected = findTheme(schemeName ?? defaultSchemeName);
if (selected != null && isPairedTheme(selected.name)) return schemeName;
return themeGroups().paired.firstOrNull?.name ?? schemeName;
}
/// Resolves the selected scheme and appearance [mode] into light and dark
/// [ColorScheme]s, mirroring desktop's System / Light / Dark behaviour.
///
/// In [ThemeMode.system] a paired theme is rendered with its pair
/// ([themePairFor]) so Flutter swaps them with the OS. An unpaired theme is
/// pinned to its own brightness. In [ThemeMode.light] and [ThemeMode.dark] the
/// theme is coerced to that brightness and the mode is forced, so the OS setting
/// is ignored.
({
ColorScheme light,
ColorScheme dark,
ThemeColors? lightTheme,
ThemeColors? darkTheme,
ThemeMode? forcedMode,
})
resolveSchemes(String? schemeName, ThemeMode mode) {
final selected =
findTheme(schemeName ?? defaultSchemeName) ??
findTheme(defaultSchemeName);
if (selected == null) {
return (
light: lightColorScheme,
dark: darkColorScheme,
lightTheme: null,
darkTheme: null,
forcedMode: null,
);
}
switch (mode) {
case ThemeMode.system:
final pairName = themePairFor(selected.name);
if (pairName == null) {
final scheme = generateColorScheme(selected);
return (
light: scheme,
dark: scheme,
lightTheme: selected,
darkTheme: selected,
forcedMode: selected.isDark ? ThemeMode.dark : ThemeMode.light,
);
}
final counterpart = findTheme(pairName) ?? selected;
final lightTheme = selected.isDark ? counterpart : selected;
final darkTheme = selected.isDark ? selected : counterpart;
return (
light: generateColorScheme(lightTheme),
dark: generateColorScheme(darkTheme),
lightTheme: lightTheme,
darkTheme: darkTheme,
forcedMode: null,
);
case ThemeMode.light:
final theme = _themeForBrightness(selected, wantDark: false);
final scheme = generateColorScheme(theme);
return (
light: scheme,
dark: scheme,
lightTheme: theme,
darkTheme: theme,
forcedMode: ThemeMode.light,
);
case ThemeMode.dark:
final theme = _themeForBrightness(selected, wantDark: true);
final scheme = generateColorScheme(theme);
return (
light: scheme,
dark: scheme,
lightTheme: theme,
darkTheme: theme,
forcedMode: ThemeMode.dark,
);
}
}
/// The theme whose colors are actually applied for [schemeName] under [mode].
///
/// In [ThemeMode.system] that is the selected theme itself — its pair covers the
/// other brightness. In the pinned modes it is the theme coerced to the pinned
/// brightness, which is what the picker highlights so the checkmark always
/// tracks what is on screen.
ThemeColors? effectiveTheme(String? schemeName, ThemeMode mode) {
final selected =
findTheme(schemeName ?? defaultSchemeName) ??
findTheme(defaultSchemeName);
if (selected == null) return null;
return switch (mode) {
ThemeMode.system => selected,
ThemeMode.light => _themeForBrightness(selected, wantDark: false),
ThemeMode.dark => _themeForBrightness(selected, wantDark: true),
};
}
/// Label for the current theme selection. System mode names the pair as a whole
/// ("Github" rather than "Github Light"), since both halves are in play.
String themeSelectionLabel(String? schemeName, ThemeMode mode) {
final theme = effectiveTheme(schemeName, mode);
if (theme == null) return defaultSchemeDisplayName;
if (mode != ThemeMode.system) return theme.displayName;
final lightMember = themePairs.containsKey(theme.name)
? theme.name
: themePairFor(theme.name);
return lightMember == null
? theme.displayName
: pairedThemeLabel(lightMember);
}
/// Coerces [selected] to the requested brightness: itself when it already
/// matches, otherwise its pair, otherwise the first catalog theme of that
/// brightness. Keeps a stored light theme usable after switching to Dark mode
/// without rewriting the user's saved selection.
ThemeColors _themeForBrightness(
ThemeColors selected, {
required bool wantDark,
}) {
if (selected.isDark == wantDark) return selected;
final pairName = themePairFor(selected.name);
final paired = pairName == null ? null : findTheme(pairName);
if (paired != null && paired.isDark == wantDark) return paired;
// Match desktop's paired-first picker ordering: an unpaired selection falls
// back through the first-party default pair, not whichever borrowed theme
// sorts first in the full brightness group.
final defaultTheme = findTheme(defaultSchemeName);
if (defaultTheme != null) {
if (defaultTheme.isDark == wantDark) return defaultTheme;
final defaultPairName = themePairFor(defaultTheme.name);
final defaultPair = defaultPairName == null
? null
: findTheme(defaultPairName);
if (defaultPair != null && defaultPair.isDark == wantDark) {
return defaultPair;
}
}
final groups = themeGroups();
final fallback = wantDark ? groups.dark : groups.light;
return fallback.isEmpty ? selected : fallback.first;
}
@@ -0,0 +1,5 @@
/// Truncates a hex pubkey to the first 8 characters with an ellipsis.
String shortPubkey(String pubkey) {
if (pubkey.length > 12) return '${pubkey.substring(0, 8)}\u2026';
return pubkey;
}
@@ -0,0 +1,272 @@
import 'dart:math' as math;
import 'dart:ui' show SemanticsRole;
import 'package:flutter/material.dart';
import '../theme/theme.dart';
const _popoverEnterDuration = Duration(milliseconds: 150);
const _popoverExitDuration = Duration(milliseconds: 110);
const _popoverStartScale = 0.96;
/// Elevation shared by anchored menus and composer popover surfaces.
const appPopoverElevation = 8.0;
/// Returns the translucent surface color shared by app popovers.
Color appPopoverColor(BuildContext context) =>
context.colors.surface.withValues(alpha: 0.98);
/// Returns the shadow color shared by app popovers.
Color appPopoverShadowColor(BuildContext context) =>
context.colors.shadow.withValues(alpha: 0.18);
/// Returns the 20px shape and composer-matching hairline shared by popovers.
RoundedRectangleBorder appPopoverShape(BuildContext context) =>
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.popover),
side: BorderSide(color: Colors.black.withValues(alpha: 0.04), width: 1),
);
/// The horizontal edge a popover aligns to on its triggering control.
enum AnchoredPopoverAlignment {
/// Aligns the popover's leading edge with the trigger's leading edge.
start,
/// Centers the popover horizontally on the trigger.
center,
/// Aligns the popover's trailing edge with the trigger's trailing edge.
end,
}
/// Shows an anchored, cross-platform popup menu with the Activity controls'
/// sizing, motion, and safe-area placement.
Future<T?> showAnchoredPopover<T>({
required BuildContext context,
required List<PopupMenuEntry<T>> items,
required double width,
required AnchoredPopoverAlignment alignment,
Color? color,
ShapeBorder? shape,
double elevation = appPopoverElevation,
Color? shadowColor,
Offset offset = Offset.zero,
EdgeInsetsGeometry menuPadding = EdgeInsets.zero,
Clip clipBehavior = Clip.antiAlias,
Key? surfaceKey,
}) {
final navigator = Navigator.of(context);
final overlay = navigator.overlay;
final triggerRenderObject = context.findRenderObject();
final overlayRenderObject = overlay?.context.findRenderObject();
if (triggerRenderObject is! RenderBox || overlayRenderObject is! RenderBox) {
return Future<T?>.value();
}
final triggerRect = MatrixUtils.transformRect(
triggerRenderObject.getTransformTo(overlayRenderObject),
Offset.zero & triggerRenderObject.size,
);
final overlayRect = Offset.zero & overlayRenderObject.size;
final mediaQuery = MediaQuery.of(context);
return navigator.push<T>(
_AnchoredPopoverRoute<T>(
position: RelativeRect.fromRect(triggerRect, overlayRect),
items: items,
width: width,
alignment: alignment,
offset: offset,
color: color ?? appPopoverColor(context),
shape: shape ?? appPopoverShape(context),
elevation: elevation,
shadowColor: shadowColor ?? appPopoverShadowColor(context),
menuPadding: menuPadding,
clipBehavior: clipBehavior,
surfaceKey: surfaceKey,
screenPadding: EdgeInsets.fromLTRB(
math.max(Grid.xxs, mediaQuery.padding.left),
math.max(Grid.xxs, mediaQuery.padding.top),
math.max(Grid.xxs, mediaQuery.padding.right),
math.max(Grid.xxs, mediaQuery.padding.bottom),
),
reducedMotion: mediaQuery.disableAnimations,
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
),
);
}
class _AnchoredPopoverRoute<T> extends PopupRoute<T> {
final RelativeRect position;
final List<PopupMenuEntry<T>> items;
final double width;
final AnchoredPopoverAlignment alignment;
final Offset offset;
final Color color;
final ShapeBorder shape;
final double elevation;
final Color shadowColor;
final EdgeInsetsGeometry menuPadding;
final Clip clipBehavior;
final Key? surfaceKey;
final EdgeInsets screenPadding;
final bool reducedMotion;
final String _barrierLabel;
_AnchoredPopoverRoute({
required this.position,
required this.items,
required this.width,
required this.alignment,
required this.offset,
required this.color,
required this.shape,
required this.elevation,
required this.shadowColor,
required this.menuPadding,
required this.clipBehavior,
required this.surfaceKey,
required this.screenPadding,
required this.reducedMotion,
required String barrierLabel,
}) : _barrierLabel = barrierLabel;
@override
Color? get barrierColor => null;
@override
bool get barrierDismissible => true;
@override
String? get barrierLabel => _barrierLabel;
@override
Duration get transitionDuration =>
reducedMotion ? Duration.zero : _popoverEnterDuration;
@override
Duration get reverseTransitionDuration =>
reducedMotion ? Duration.zero : _popoverExitDuration;
@override
Widget buildPage(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
) {
final curvedAnimation = animation.drive(
CurveTween(curve: Curves.easeOutCubic),
);
final scaleAnimation = Tween<double>(
begin: _popoverStartScale,
end: 1,
).animate(curvedAnimation);
final transformOrigin = switch (alignment) {
AnchoredPopoverAlignment.start => Alignment.topLeft,
AnchoredPopoverAlignment.center => Alignment.topCenter,
AnchoredPopoverAlignment.end => Alignment.topRight,
};
return CustomSingleChildLayout(
delegate: _AnchoredPopoverLayoutDelegate(
position: position,
alignment: alignment,
offset: offset,
screenPadding: screenPadding,
),
child: FadeTransition(
key: const ValueKey('activity-popover-fade'),
opacity: curvedAnimation,
child: ScaleTransition(
key: const ValueKey('activity-popover-scale'),
scale: scaleAnimation,
alignment: transformOrigin,
child: Material(
key: surfaceKey,
type: MaterialType.card,
color: color,
surfaceTintColor: Colors.transparent,
elevation: elevation,
shadowColor: shadowColor,
shape: shape,
clipBehavior: clipBehavior,
child: SizedBox(
width: width,
child: Semantics(
role: SemanticsRole.menu,
scopesRoute: true,
namesRoute: true,
explicitChildNodes: true,
child: SingleChildScrollView(
padding: menuPadding,
child: ListBody(children: items),
),
),
),
),
),
),
);
}
}
class _AnchoredPopoverLayoutDelegate extends SingleChildLayoutDelegate {
final RelativeRect position;
final AnchoredPopoverAlignment alignment;
final Offset offset;
final EdgeInsets screenPadding;
const _AnchoredPopoverLayoutDelegate({
required this.position,
required this.alignment,
required this.offset,
required this.screenPadding,
});
@override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
return BoxConstraints.loose(
Size(
constraints.maxWidth - screenPadding.horizontal,
constraints.maxHeight - screenPadding.vertical,
),
);
}
@override
Offset getPositionForChild(Size size, Size childSize) {
final anchorBottom = size.height - position.bottom;
final desiredX = switch (alignment) {
AnchoredPopoverAlignment.start => position.left + offset.dx,
AnchoredPopoverAlignment.center =>
position.left +
(size.width - position.left - position.right - childSize.width) /
2 +
offset.dx,
AnchoredPopoverAlignment.end =>
size.width - position.right - childSize.width + offset.dx,
};
final minX = screenPadding.left;
final maxX = size.width - screenPadding.right - childSize.width;
final x = desiredX.clamp(minX, maxX).toDouble();
final belowY = anchorBottom + offset.dy;
final aboveY = position.top - childSize.height - offset.dy;
final maxY = size.height - screenPadding.bottom - childSize.height;
final desiredY =
belowY + childSize.height <= size.height - screenPadding.bottom
? belowY
: aboveY;
final y = desiredY.clamp(screenPadding.top, maxY).toDouble();
return Offset(x, y);
}
@override
bool shouldRelayout(_AnchoredPopoverLayoutDelegate oldDelegate) {
return position != oldDelegate.position ||
alignment != oldDelegate.alignment ||
offset != oldDelegate.offset ||
screenPadding != oldDelegate.screenPadding;
}
}
+172
View File
@@ -0,0 +1,172 @@
import 'package:flutter/material.dart';
import '../theme/theme.dart';
import 'app_list_inset.dart';
/// Widest an inline [AppListRow.value] may grow before it ellipsises, leaving
/// room for a reasonable title beside it.
const double _maxValueWidth = 180.0;
/// Row height comes entirely from this padding — rows carry a single line of
/// text most of the time, so it sets how airy a card reads.
const double _rowVerticalPadding = Grid.xs;
/// A flush, borderless settings/list row: leading icon, title, optional
/// subtitle and trailing widget. Its own background comes from whatever
/// contains it — a grouped card, or the page itself.
class AppListRow extends StatelessWidget {
const AppListRow({
super.key,
this.icon,
required this.title,
this.subtitle,
this.subtitleStyle,
this.subtitleMaxLines,
this.value,
this.trailing,
this.titleColor,
this.onTap,
});
final IconData? icon;
final String title;
final String? subtitle;
final TextStyle? subtitleStyle;
final int? subtitleMaxLines;
/// The row's current setting, shown muted on the trailing side next to
/// [trailing] — for rows whose value is short enough to sit inline.
final String? value;
final Widget? trailing;
final Color? titleColor;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final row = Padding(
padding: EdgeInsets.symmetric(
horizontal: AppListInset.of(context),
vertical: _rowVerticalPadding,
),
child: Row(
// Centred rather than baseline-aligned to the title: on a two-line row
// the icon and trailing control read as belonging to the row, not to
// its first line.
children: [
if (icon != null) ...[
Icon(
icon,
size: 22,
color: titleColor ?? context.colors.onSurfaceVariant,
),
const SizedBox(width: Grid.xs),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: context.textTheme.bodyLarge?.copyWith(
color: titleColor,
),
),
if (subtitle != null) ...[
const SizedBox(height: Grid.quarter),
Text(
subtitle!,
style:
subtitleStyle ??
context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
maxLines: subtitleMaxLines,
overflow: subtitleMaxLines == null
? null
: TextOverflow.ellipsis,
),
],
],
),
),
if (value != null) ...[
const SizedBox(width: Grid.xxs),
// Inflexible, so the title's Expanded absorbs the slack and the
// value stays flush against the trailing edge; capped instead of
// flexed so a long value ellipsises rather than overflowing.
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: _maxValueWidth),
child: Text(
value!,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
),
),
],
if (trailing != null) ...[const SizedBox(width: Grid.xxs), trailing!],
],
),
);
if (onTap == null) return row;
return InkWell(onTap: onTap, child: row);
}
}
/// A custom leading widget variant of [AppListRow] for rows whose leading
/// slot is not a plain [IconData] (e.g. an emoji or image).
class AppListRowRaw extends StatelessWidget {
const AppListRowRaw({
super.key,
required this.leading,
required this.title,
this.subtitle,
this.trailing,
this.onTap,
});
final Widget leading;
final Widget title;
final Widget? subtitle;
final Widget? trailing;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final row = Padding(
padding: EdgeInsets.symmetric(
horizontal: AppListInset.of(context),
vertical: _rowVerticalPadding,
),
child: Row(
children: [
leading,
const SizedBox(width: Grid.xs),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
title,
if (subtitle != null) ...[
const SizedBox(height: Grid.quarter),
subtitle!,
],
],
),
),
if (trailing != null) ...[const SizedBox(width: Grid.xxs), trailing!],
],
),
);
if (onTap == null) return row;
return InkWell(onTap: onTap, child: row);
}
}
@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import '../theme/theme.dart';
import 'app_list_inset.dart';
/// A group of list rows in a rounded container, with an optional [label] above
/// it. Rows inside are hairline-separated and inset to the card rather than the
/// page, via [AppListInset].
class AppListCard extends StatelessWidget {
const AppListCard({super.key, this.label, required this.children});
/// Rendered above the card in sentence case, as written — no uppercasing.
final String? label;
final List<Widget> children;
static const _inset = Grid.xs;
/// Separators start at the label column, clearing the leading icon.
static const _dividerIndent = _inset + _iconColumnWidth;
static const _iconColumnWidth = 22.0 + Grid.xs;
@override
Widget build(BuildContext context) {
final separated = <Widget>[];
for (var index = 0; index < children.length; index++) {
if (index > 0) {
separated.add(
Divider(
height: 1,
thickness: 1,
indent: _dividerIndent,
endIndent: _inset,
// The scheme's own border tokens are derived from the page surface,
// which lands them within a few levels of the card fill — invisible.
// Tinting with the text color instead keeps the hairline readable on
// the card in both brightnesses.
color: context.colors.onSurface.withValues(alpha: 0.12),
),
);
}
separated.add(children[index]);
}
return Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
Grid.xxs,
Grid.gutter,
Grid.xxs,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (label != null)
Padding(
padding: const EdgeInsets.only(left: Grid.half, bottom: Grid.xxs),
child: Text(
label!,
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
),
Material(
// The softest step on the elevation ramp — a rung below the home tab
// bar's active pill (primaryContainer, see PR #2810), since a
// full-width card at the pill's contrast reads heavier than the pill
// does. The dividers carry the group structure, so the fill only has
// to separate the card from the page.
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.card),
// Keeps row ripples inside the rounded corners.
clipBehavior: Clip.antiAlias,
child: AppListInset(
horizontal: _inset,
child: Column(children: separated),
),
),
],
),
);
}
}
@@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
import '../theme/theme.dart';
/// The horizontal padding list rows use, allowing grouped containers to replace
/// the page-level inset without changing each row.
class AppListInset extends InheritedWidget {
const AppListInset({
super.key,
required this.horizontal,
required super.child,
});
final double horizontal;
static double of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<AppListInset>()?.horizontal ??
Grid.gutter;
@override
bool updateShouldNotify(AppListInset oldWidget) =>
oldWidget.horizontal != horizontal;
}
+176
View File
@@ -0,0 +1,176 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../relay/relay.dart';
/// A circular avatar that supports both remote URLs and inline image data.
///
/// Flutter's [NetworkImage] only loads network URLs, while desktop browsers also
/// accept `data:image/*` sources directly. Agent emoji avatars are inline SVGs,
/// so mobile must decode those before rendering them.
class AvatarImage extends StatelessWidget {
final String? imageUrl;
final double radius;
final Color? backgroundColor;
final Widget fallback;
const AvatarImage({
super.key,
required this.imageUrl,
required this.radius,
required this.fallback,
this.backgroundColor,
});
@override
Widget build(BuildContext context) {
return CircleAvatar(
radius: radius,
backgroundColor: backgroundColor,
child: ClipOval(
child: SizedBox.square(
dimension: radius * 2,
child: AvatarImageContent(imageUrl: imageUrl, fallback: fallback),
),
),
);
}
}
/// Image content for avatar surfaces whose shape is supplied by their parent.
class AvatarImageContent extends StatefulWidget {
final String? imageUrl;
final Widget fallback;
final BoxFit fit;
const AvatarImageContent({
super.key,
required this.imageUrl,
required this.fallback,
this.fit = BoxFit.cover,
});
@override
State<AvatarImageContent> createState() => _AvatarImageContentState();
}
class _AvatarImageContentState extends State<AvatarImageContent> {
late _AvatarSource? _source = _AvatarSource.parse(widget.imageUrl);
@override
void didUpdateWidget(AvatarImageContent oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.imageUrl != oldWidget.imageUrl) {
_source = _AvatarSource.parse(widget.imageUrl);
}
}
@override
Widget build(BuildContext context) {
final centeredFallback = Center(child: widget.fallback);
return switch (_source) {
_EmojiAvatarSource(:final emoji, :final color) => ColoredBox(
color: color,
child: LayoutBuilder(
builder: (_, constraints) => Center(
child: Text(
emoji,
textScaler: TextScaler.noScaling,
style: TextStyle(
fontSize: constraints.biggest.shortestSide * 258 / 512,
height: 1,
),
),
),
),
),
_SvgAvatarSource(:final svg) => SvgPicture.string(
svg,
fit: widget.fit,
placeholderBuilder: (_) => centeredFallback,
errorBuilder: (_, _, _) => centeredFallback,
),
_RasterDataAvatarSource(:final bytes) => Image.memory(
bytes,
fit: widget.fit,
errorBuilder: (_, _, _) => centeredFallback,
),
_NetworkAvatarSource(:final url) => MediaImage(
url: url,
fit: widget.fit,
errorBuilder: (_, _, _) => centeredFallback,
),
null => centeredFallback,
};
}
}
sealed class _AvatarSource {
const _AvatarSource();
static _AvatarSource? parse(String? value) {
final url = value?.trim();
if (url == null || url.isEmpty) return null;
if (!url.startsWith('data:image/')) return _NetworkAvatarSource(url);
try {
final data = UriData.parse(url);
if (data.mimeType == 'image/svg+xml') {
final Uint8List bytes = data.contentAsBytes();
final svg = utf8.decode(bytes);
return _parseEmojiAvatar(svg) ?? _SvgAvatarSource(svg);
}
return _RasterDataAvatarSource(data.contentAsBytes());
} on FormatException {
return null;
}
}
}
_EmojiAvatarSource? _parseEmojiAvatar(String svg) {
final colorValue = RegExp(
r'<rect\b[^>]*\sfill="([^"]+)"',
).firstMatch(svg)?[1];
final emojiValue = RegExp(r'<text\b[^>]*>(.*?)</text>').firstMatch(svg)?[1];
if (colorValue == null || emojiValue == null) return null;
final color = _parseHexColor(colorValue);
if (color == null) return null;
final emoji = emojiValue
.replaceAll('&gt;', '>')
.replaceAll('&lt;', '<')
.replaceAll('&amp;', '&');
return _EmojiAvatarSource(emoji, color);
}
Color? _parseHexColor(String value) {
final hex = value.startsWith('#') ? value.substring(1) : value;
if (!RegExp(r'^[0-9a-fA-F]{6}$').hasMatch(hex)) return null;
final rgb = int.tryParse(hex, radix: 16);
return rgb == null ? null : Color(0xFF000000 | rgb);
}
class _EmojiAvatarSource extends _AvatarSource {
final String emoji;
final Color color;
const _EmojiAvatarSource(this.emoji, this.color);
}
class _SvgAvatarSource extends _AvatarSource {
final String svg;
const _SvgAvatarSource(this.svg);
}
class _RasterDataAvatarSource extends _AvatarSource {
final Uint8List bytes;
const _RasterDataAvatarSource(this.bytes);
}
class _NetworkAvatarSource extends _AvatarSource {
final String url;
const _NetworkAvatarSource(this.url);
}
@@ -0,0 +1,441 @@
import 'dart:async' show Timer, unawaited;
import 'dart:math' show cos, min, pi, sin;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../l10n/l10n.dart';
import '../theme/theme.dart';
import 'flapping_bee.dart';
/// Replaces the standard pull-to-refresh spinner with Buzz's loading bee.
///
/// Flutter continues to own the gesture, refresh lifecycle, and accessibility
/// semantics. This widget maps those states into the elastic pull, retained
/// loading gap, and bee animation.
class BeeRefreshIndicator extends HookConsumerWidget {
/// Called when the user completes a pull, to load fresh data.
///
/// The bee keeps flapping until this future settles, so it should complete
/// only once the refresh is done.
final Future<void> Function() onRefresh;
/// The scrollable this indicator wraps.
///
/// It must scroll vertically; the indicator reads its scroll notifications
/// to couple the bee to the user's finger.
final Widget child;
/// The vertical offset of the scrollable's top edge, such as a pinned header.
final double edgeOffset;
const BeeRefreshIndicator({
required this.onRefresh,
required this.child,
this.edgeOffset = 0,
super.key,
});
static const _beeWidth = 60.0;
static const _beeHeight = _beeWidth * 309 / 466;
static const _triggerDistance = 100.0;
static const _loadingGap = 72.0;
static const _beeVerticalAlignment = 0.75;
static const _beeInitialScale = 0.6;
static const _beeRevealStartProgress = 0.18;
static const _pupilStartBeyondArmDistance = 96.0;
static const _pupilFullBeforeEmojiDistance = 8.0;
static const _eyeEmojiSwapViewportFraction = 0.26;
static const _eyeEmojiMinSwapBeyondArmDistance = 220.0;
static const _eyeEmojiMaxSwapBeyondArmDistance = 280.0;
static const _pupilMinBeyondArmDuration = Duration(milliseconds: 300);
static const _eyeEmojiMinBeyondArmDuration = Duration(milliseconds: 700);
static const _eyeShakePeriod = Duration(milliseconds: 140);
static const _settleDuration = Duration(milliseconds: 180);
@override
Widget build(BuildContext context, WidgetRef ref) {
final status = useState<RefreshIndicatorStatus?>(null);
final pullProgress = useState(0.0);
final pullDistance = useState(0.0);
final pullBeyondArmDistance = useState(0.0);
final pullBeyondArmDuration = useState(Duration.zero);
final activePointers = useRef(<int>{});
final lastPointerTime = useRef(Duration.zero);
final armedAt = useRef<Duration?>(null);
final didTriggerArmHaptic = useRef(false);
final didTriggerEmojiHaptic = useRef(false);
final eyeShakeHapticTimer = useRef<Timer?>(null);
final completionController = useAnimationController(
duration: _settleDuration,
);
final gapController = useAnimationController(
duration: _settleDuration,
reverseDuration: _settleDuration,
);
final flapController = useAnimationController(
duration: const Duration(milliseconds: 480),
);
final eyeShakeController = useAnimationController(
duration: _eyeShakePeriod,
);
final completionProgress = useAnimation(completionController);
final gapProgress = useAnimation(gapController);
final flapProgress = useAnimation(flapController);
final eyeShakeProgress = useAnimation(eyeShakeController);
final reducedMotion = MediaQuery.disableAnimationsOf(context);
useEffect(
() =>
() => eyeShakeHapticTimer.value?.cancel(),
const [],
);
final eyeEmojiSwapDistance =
(MediaQuery.sizeOf(context).height * _eyeEmojiSwapViewportFraction)
.clamp(
_eyeEmojiMinSwapBeyondArmDistance,
_eyeEmojiMaxSwapBeyondArmDistance,
)
.toDouble();
void stopEyeShake() {
eyeShakeController
..stop()
..reset();
eyeShakeHapticTimer.value?.cancel();
eyeShakeHapticTimer.value = null;
}
void startEyeShake() {
stopEyeShake();
if (reducedMotion) return;
eyeShakeController.repeat();
eyeShakeHapticTimer.value = Timer.periodic(
_eyeShakePeriod,
(_) => unawaited(HapticFeedback.selectionClick()),
);
}
void beginExpressionTracking() {
if (activePointers.value.isEmpty || armedAt.value != null) return;
pullBeyondArmDistance.value = 0;
pullBeyondArmDuration.value = Duration.zero;
armedAt.value = lastPointerTime.value;
if (!didTriggerArmHaptic.value) {
didTriggerArmHaptic.value = true;
unawaited(HapticFeedback.mediumImpact());
}
}
void updateStatus(RefreshIndicatorStatus? nextStatus) {
status.value = nextStatus;
if (nextStatus == RefreshIndicatorStatus.drag) {
completionController.reset();
gapController.reset();
flapController.stop();
if (!didTriggerArmHaptic.value) {
pullBeyondArmDistance.value = 0;
pullBeyondArmDuration.value = Duration.zero;
armedAt.value = null;
}
} else if (nextStatus == RefreshIndicatorStatus.armed) {
pullProgress.value = 1;
beginExpressionTracking();
} else if (nextStatus == RefreshIndicatorStatus.snap ||
nextStatus == RefreshIndicatorStatus.refresh) {
stopEyeShake();
pullProgress.value = 1;
pullBeyondArmDistance.value = 0;
pullBeyondArmDuration.value = Duration.zero;
armedAt.value = null;
if (reducedMotion) {
gapController.value = 1;
} else {
gapController.animateTo(1, curve: Curves.easeOutCubic);
}
if (!reducedMotion) flapController.repeat();
} else if (nextStatus == RefreshIndicatorStatus.done) {
stopEyeShake();
if (reducedMotion) {
gapController.value = 0;
pullProgress.value = 0;
pullDistance.value = 0;
pullBeyondArmDistance.value = 0;
pullBeyondArmDuration.value = Duration.zero;
status.value = null;
} else {
flapController.repeat();
gapController
.animateTo(1, curve: Curves.easeOutCubic)
.whenCompleteOrCancel(() {
if (!gapController.isCompleted || status.value == null) return;
gapController.animateBack(0, curve: Curves.easeInOutCubic);
completionController.forward(from: 0).whenCompleteOrCancel(() {
if (!completionController.isCompleted) return;
flapController.stop();
pullProgress.value = 0;
pullDistance.value = 0;
pullBeyondArmDistance.value = 0;
pullBeyondArmDuration.value = Duration.zero;
status.value = null;
});
});
}
} else if (nextStatus == RefreshIndicatorStatus.canceled ||
nextStatus == null) {
stopEyeShake();
pullProgress.value = 0;
pullDistance.value = 0;
pullBeyondArmDistance.value = 0;
pullBeyondArmDuration.value = Duration.zero;
armedAt.value = null;
if (!gapController.isAnimating) gapController.reset();
flapController.stop();
}
}
bool trackPull(ScrollNotification notification) {
if (notification.metrics.axis != Axis.vertical) return false;
if (notification is! ScrollStartNotification &&
notification.metrics.extentBefore == 0) {
// BouncingScrollPhysics reports a live negative scroll position while
// the user is pulling. Reading it keeps the bee coupled to the finger.
final elasticPull =
(notification.metrics.minScrollExtent - notification.metrics.pixels)
.clamp(0.0, double.infinity)
.toDouble();
if (elasticPull > 0 || pullDistance.value > 0) {
pullDistance.value = elasticPull;
final nextProgress = (elasticPull / _triggerDistance).clamp(0.0, 1.0);
pullProgress.value = nextProgress;
if (nextProgress >= 1) beginExpressionTracking();
} else if (notification case OverscrollNotification()) {
// Clamping physics does not expose a negative position, so build the
// same progress from its overscroll deltas.
final nextDistance =
pullDistance.value + notification.overscroll.abs();
pullDistance.value = nextDistance;
pullProgress.value = (nextDistance / _triggerDistance).clamp(
0.0,
1.0,
);
if (pullProgress.value >= 1) beginExpressionTracking();
}
}
return false;
}
void startPointer(PointerDownEvent event) {
final isNewGesture = activePointers.value.isEmpty;
activePointers.value.add(event.pointer);
if (!isNewGesture) return;
lastPointerTime.value = event.timeStamp;
armedAt.value = null;
didTriggerArmHaptic.value = false;
didTriggerEmojiHaptic.value = false;
stopEyeShake();
pullProgress.value = 0;
pullDistance.value = 0;
pullBeyondArmDistance.value = 0;
pullBeyondArmDuration.value = Duration.zero;
}
void trackPointer(PointerMoveEvent event) {
if (!activePointers.value.contains(event.pointer)) return;
lastPointerTime.value = event.timeStamp;
if (armedAt.value == null) return;
pullBeyondArmDistance.value =
(pullBeyondArmDistance.value + event.delta.dy)
.clamp(0.0, double.infinity)
.toDouble();
if (armedAt.value case final armedTime?) {
pullBeyondArmDuration.value = event.timeStamp - armedTime;
}
if (!didTriggerEmojiHaptic.value &&
pullBeyondArmDuration.value >= _eyeEmojiMinBeyondArmDuration &&
pullBeyondArmDistance.value >= eyeEmojiSwapDistance) {
didTriggerEmojiHaptic.value = true;
unawaited(HapticFeedback.heavyImpact());
startEyeShake();
}
}
void finishPointer(PointerEvent event) {
if (!activePointers.value.remove(event.pointer)) return;
if (activePointers.value.isNotEmpty) return;
stopEyeShake();
armedAt.value = null;
pullBeyondArmDistance.value = 0;
pullBeyondArmDuration.value = Duration.zero;
}
final isLoading = switch (status.value) {
RefreshIndicatorStatus.snap ||
RefreshIndicatorStatus.refresh ||
RefreshIndicatorStatus.done => true,
_ => false,
};
final dragRevealProgress =
((pullProgress.value - _beeRevealStartProgress) /
(1 - _beeRevealStartProgress))
.clamp(0.0, 1.0);
final isVisible =
status.value != null &&
(isLoading || dragRevealProgress > 0 || completionProgress > 0);
final retainedGap = _loadingGap * gapProgress;
final visibleGap = pullDistance.value + retainedGap;
final top = edgeOffset + (visibleGap - _beeHeight) * _beeVerticalAlignment;
final opacity = isLoading ? 1 - completionProgress : dragRevealProgress;
final beeScale = isLoading
? 1.0
: _beeInitialScale + (1 - _beeInitialScale) * dragRevealProgress;
final flapAmount = reducedMotion
? 0.0
: isLoading
? 0.5 - (0.5 * cos(flapProgress * 4 * pi))
: pullProgress.value * 0.18;
final pupilFullDistance =
eyeEmojiSwapDistance - _pupilFullBeforeEmojiDistance;
final pupilDistanceProgress =
((pullBeyondArmDistance.value - _pupilStartBeyondArmDistance) /
(pupilFullDistance - _pupilStartBeyondArmDistance))
.clamp(0.0, 1.0);
final pupilGrowthDuration =
_eyeEmojiMinBeyondArmDuration - _pupilMinBeyondArmDuration;
final pupilProgress =
pullBeyondArmDuration.value >= _pupilMinBeyondArmDuration
? min(
pupilDistanceProgress,
((pullBeyondArmDuration.value - _pupilMinBeyondArmDuration)
.inMicroseconds /
pupilGrowthDuration.inMicroseconds)
.clamp(0.0, 1.0),
).toDouble()
: 0.0;
final showEyeEmoji = !isLoading && didTriggerEmojiHaptic.value;
final eyeShakeOffset = showEyeEmoji && !reducedMotion
? sin(eyeShakeProgress * 2 * pi) * 0.75
: 0.0;
final scrollBehavior = ScrollConfiguration.of(context).copyWith(
overscroll: false,
physics: const BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics(),
),
);
return Stack(
clipBehavior: Clip.none,
children: [
RefreshIndicator.noSpinner(
onRefresh: onRefresh,
onStatusChange: updateStatus,
semanticsLabel: context.l10n.pullToRefresh,
child: NotificationListener<ScrollNotification>(
onNotification: trackPull,
child: Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: startPointer,
onPointerMove: trackPointer,
onPointerUp: finishPointer,
onPointerCancel: finishPointer,
child: ScrollConfiguration(
behavior: scrollBehavior,
child: Transform.translate(
key: const ValueKey('bee-refresh-retained-gap'),
offset: Offset(0, retainedGap),
child: child,
),
),
),
),
),
if (isVisible)
Positioned(
top: edgeOffset,
left: 0,
right: 0,
bottom: 0,
child: ClipRect(
child: Align(
alignment: Alignment.topCenter,
child: Transform.translate(
offset: Offset(0, top - edgeOffset),
child: IgnorePointer(
child: Opacity(
key: const ValueKey('bee-refresh-opacity'),
opacity: opacity.clamp(0.0, 1.0),
child: Transform.scale(
key: const ValueKey('bee-refresh-scale'),
scale: beeScale,
child: SizedBox(
width: _beeWidth,
height: _beeHeight,
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.topCenter,
children: [
Positioned.fill(
child: FlappingBee(
width: _beeWidth,
color: context.colors.primary,
flapAmount: flapAmount,
eyeProgress:
!isLoading &&
!showEyeEmoji &&
pupilProgress > 0
? pupilProgress
: null,
),
),
if (showEyeEmoji)
Positioned(
top: 2,
left: 0,
right: 0,
child: ExcludeSemantics(
child: Transform.translate(
key: const ValueKey(
'bee-refresh-eyes-emoji-offset',
),
offset: const Offset(2, 0),
child: Transform.translate(
key: const ValueKey(
'bee-refresh-eyes-emoji-shake',
),
offset: Offset(eyeShakeOffset, 0),
child: const Text(
'👀',
key: ValueKey(
'bee-refresh-eyes-emoji',
),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18,
height: 1,
),
),
),
),
),
),
],
),
),
),
),
),
),
),
),
),
],
);
}
}
@@ -0,0 +1,99 @@
import 'dart:math' show pi;
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../l10n/l10n.dart';
import '../theme/theme.dart';
/// The shared mobile loading indicator, matching the desktop arc spinner.
class BuzzLoadingIndicator extends HookConsumerWidget {
/// The spinner diameter.
final double size;
/// An optional spinner color. Defaults to the active accent color.
final Color? color;
/// The accessibility announcement for this loading state.
final String? semanticLabel;
/// Creates a looping arc loading indicator.
const BuzzLoadingIndicator({
this.size = 40,
this.color,
this.semanticLabel,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final animation = useAnimationController(
duration: const Duration(milliseconds: 500),
);
useEffect(() {
if (reducedMotion) {
animation
..stop()
..value = 0;
} else {
animation.repeat();
}
return animation.stop;
}, [animation, reducedMotion]);
final spinnerColor = color ?? context.colors.primary;
final strokeWidth = (size / 6).clamp(2.0, 4.0);
return Semantics(
liveRegion: true,
label: semanticLabel ?? context.l10n.loading,
child: ExcludeSemantics(
child: RotationTransition(
key: const ValueKey('buzz-loading-indicator-spinner'),
turns: animation,
child: CustomPaint(
size: Size.square(size),
painter: _ArcSpinnerPainter(
color: spinnerColor,
strokeWidth: strokeWidth,
),
),
),
),
);
}
}
class _ArcSpinnerPainter extends CustomPainter {
final Color color;
final double strokeWidth;
const _ArcSpinnerPainter({required this.color, required this.strokeWidth});
@override
void paint(Canvas canvas, Size size) {
final center = size.center(Offset.zero);
final radius = (size.shortestSide - strokeWidth) / 2;
final bounds = Rect.fromCircle(center: center, radius: radius);
final trackPaint = Paint()
..color = color.withValues(alpha: color.a * 0.1)
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth;
final arcPaint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth;
canvas
..drawCircle(center, radius, trackPaint)
..drawArc(bounds, -pi / 2, pi / 2, false, arcPaint);
}
@override
bool shouldRepaint(_ArcSpinnerPainter oldDelegate) {
return color != oldDelegate.color || strokeWidth != oldDelegate.strokeWidth;
}
}
@@ -0,0 +1,91 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import '../theme/theme.dart';
/// An iOS-native sheet surface that adopts the system's concentric corners on
/// iOS 26 and newer. Other platforms keep the normal Flutter shape.
class ConcentricSheetSurface extends HookWidget {
const ConcentricSheetSurface({
required this.child,
required this.enabled,
this.color,
super.key,
});
final Widget child;
final bool enabled;
final Color? color;
static const _surfaceChannel = MethodChannel('buzz/concentric_sheet_surface');
Future<bool> _checkNativeSurfaceSupport() async {
try {
final supported = await _surfaceChannel.invokeMethod<bool>('isSupported');
return supported == true;
} on MissingPluginException {
// The registrar is unavailable, so retain the Flutter surface.
return false;
} on PlatformException {
// The native surface is optional; retain the Flutter surface on failure.
return false;
}
}
@override
Widget build(BuildContext context) {
final shouldCheckNativeSurface =
enabled && defaultTargetPlatform == TargetPlatform.iOS;
final supportFuture = useMemoized(
() => shouldCheckNativeSurface
? _checkNativeSurfaceSupport()
: Future<bool>.value(false),
[shouldCheckNativeSurface],
);
final nativeSurfaceSupported = useFuture(supportFuture).data ?? false;
if (!shouldCheckNativeSurface) {
return child;
}
final surfaceColor = color ?? context.colors.surface;
return Padding(
padding: const EdgeInsets.only(
left: Grid.xxs,
right: Grid.xxs,
bottom: Grid.xxs,
),
child: Stack(
children: [
if (nativeSurfaceSupported)
Positioned.fill(
child: ExcludeSemantics(
child: UiKitView(
viewType: 'buzz/concentric_sheet_surface',
hitTestBehavior: PlatformViewHitTestBehavior.transparent,
creationParams: <String, Object>{
'color': surfaceColor.toARGB32(),
'minimumRadius': Radii.dialog,
},
creationParamsCodec: const StandardMessageCodec(),
),
),
)
else
Positioned.fill(
child: Material(
color: surfaceColor,
borderRadius: BorderRadius.circular(Radii.dialog),
clipBehavior: Clip.antiAlias,
),
),
child,
],
),
);
}
}
@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
/// Supplies a shared directional entrance to separate foreground surfaces.
///
/// Descendants opt in with [DirectionalTransitionMotion], which lets a page
/// move its body and app-bar content together while leaving decorative
/// backgrounds stationary.
class DirectionalTransitionScope extends InheritedWidget {
/// Horizontal displacement remaining in the transition.
final double horizontalOffset;
/// Current foreground opacity, from zero to one.
final double opacity;
/// Creates a directional transition scope.
const DirectionalTransitionScope({
super.key,
required this.horizontalOffset,
required this.opacity,
required super.child,
});
/// Returns the closest transition, or null outside a transitioning surface.
static DirectionalTransitionScope? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<DirectionalTransitionScope>();
@override
bool updateShouldNotify(DirectionalTransitionScope oldWidget) =>
horizontalOffset != oldWidget.horizontalOffset ||
opacity != oldWidget.opacity;
}
/// Applies the nearest [DirectionalTransitionScope] to one foreground layer.
class DirectionalTransitionMotion extends StatelessWidget {
/// Foreground content that participates in the shared transition.
final Widget child;
/// Optional key for inspecting the composited translation layer.
final Key? transformKey;
/// Optional key for inspecting the composited opacity layer.
final Key? opacityKey;
/// Creates a foreground transition participant.
const DirectionalTransitionMotion({
super.key,
required this.child,
this.transformKey,
this.opacityKey,
});
@override
Widget build(BuildContext context) {
final transition = DirectionalTransitionScope.maybeOf(context);
if (transition == null) return child;
return Transform.translate(
key: transformKey,
offset: Offset(transition.horizontalOffset, 0),
child: Opacity(
key: opacityKey,
opacity: transition.opacity,
child: child,
),
);
}
}
@@ -0,0 +1,160 @@
import 'package:flutter/material.dart';
import '../theme/theme.dart';
/// A single entry in a [FilterChipBar].
class FilterChipItem<T> {
/// Value identifying this chip; compared against the bar's `selected`.
final T id;
/// Visible label.
final String label;
/// Optional leading icon.
final IconData? icon;
/// Optional count appended to the label as " (n)".
final int? count;
const FilterChipItem({
required this.id,
required this.label,
this.icon,
this.count,
});
}
/// A horizontally-scrolling row of Material [FilterChip]s.
///
/// This is the single shared filter/segment selector used across Pulse,
/// Search, and Activity. It relies on the app's `chipTheme` for styling so
/// every screen looks identical — do not hand-roll chip rows elsewhere.
class FilterChipBar<T> extends StatelessWidget {
final List<FilterChipItem<T>> items;
final T selected;
final ValueChanged<T> onSelected;
/// When enabled, every chip shares the width rather than scrolling.
final bool expandItems;
/// Material density applied to each chip.
final VisualDensity visualDensity;
/// Vertical padding around the content inside each chip.
final double chipVerticalPadding;
/// Vertical spacing around the chip row.
final double barVerticalPadding;
const FilterChipBar({
super.key,
required this.items,
required this.selected,
required this.onSelected,
this.expandItems = false,
this.visualDensity = VisualDensity.compact,
this.chipVerticalPadding = Grid.quarter,
this.barVerticalPadding = Grid.xxs,
});
@override
Widget build(BuildContext context) {
final chips = [
for (var index = 0; index < items.length; index++) ...[
if (index > 0) const SizedBox(width: Grid.xxs),
if (expandItems)
Expanded(child: _chip(context, items[index], fillWidth: true))
else
_chip(context, items[index]),
],
];
if (expandItems) {
return Padding(
padding: EdgeInsets.symmetric(
horizontal: Grid.gutter,
vertical: barVerticalPadding,
),
child: Row(children: chips),
);
}
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: EdgeInsets.symmetric(
horizontal: Grid.gutter,
vertical: barVerticalPadding,
),
child: Row(children: chips),
);
}
Widget _chip(
BuildContext context,
FilterChipItem<T> item, {
bool fillWidth = false,
}) {
final isSelected = selected == item.id;
final text = item.count != null
? '${item.label} (${item.count})'
: item.label;
final fg = isSelected
? context.colors.onPrimary
: context.colors.onSurfaceVariant;
final labelStyle = filterChipTextStyle.copyWith(
color: fg,
fontWeight: isSelected ? FontWeight.w500 : FontWeight.w400,
);
final label = item.icon != null
? Row(
mainAxisSize: fillWidth ? MainAxisSize.max : MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(item.icon, size: 14, color: fg),
const SizedBox(width: Grid.half),
if (fillWidth)
Flexible(
child: Text(
text,
textAlign: TextAlign.center,
style: labelStyle,
),
)
else
Text(text, style: labelStyle),
],
)
: Text(
text,
textAlign: fillWidth ? TextAlign.center : TextAlign.start,
style: labelStyle,
);
final centeredLabel = Align(
alignment: Alignment.center,
widthFactor: fillWidth ? null : 1,
heightFactor: 1,
child: label,
);
final chip = FilterChip(
selected: isSelected,
showCheckmark: false,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.lg),
),
side: BorderSide.none,
label: fillWidth
? SizedBox(width: double.infinity, child: centeredLabel)
: centeredLabel,
labelPadding: EdgeInsets.zero,
onSelected: (_) => onSelected(item.id),
padding: EdgeInsets.symmetric(
horizontal: fillWidth ? Grid.quarter : Grid.twelve,
vertical: chipVerticalPadding,
),
visualDensity: visualDensity,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
);
return fillWidth ? SizedBox(width: double.infinity, child: chip) : chip;
}
}
+155
View File
@@ -0,0 +1,155 @@
import 'dart:math' show min;
import 'package:flutter/material.dart';
/// The Buzz mark at a caller-controlled wing position.
///
/// The geometry matches the desktop loading bee. Callers drive the wings
/// themselves, which lets one painter serve both the tap-to-flutter mark
/// ([TappableFlappingBee]) and the pull-to-refresh indicator
/// ([BeeRefreshIndicator]).
class FlappingBee extends StatelessWidget {
/// The rendered width of the complete bee mark.
///
/// Height follows from the mark's 466:309 aspect ratio.
final double width;
/// The color used for the bee silhouette, wings, and pupils.
final Color color;
/// How far the wings are tucked toward the body, from 0 to 1.
///
/// 0 renders the wings fully spread; 1 renders them at their innermost
/// tuck. Callers animate this to flap the wings.
final double flapAmount;
/// How far the pupils have grown, from 0 to 1, or null for cutout eyes.
///
/// Only the pull-to-refresh treatment sets this. Leaving it null preserves
/// the mark's ordinary cutout eyes; a non-null value fills them in, with 1
/// drawing the pupils at full size.
final double? eyeProgress;
const FlappingBee({
required this.width,
required this.color,
required this.flapAmount,
this.eyeProgress,
super.key,
});
@override
Widget build(BuildContext context) {
return RepaintBoundary(
child: CustomPaint(
size: Size(width, width * 309 / 466),
painter: _FlappingBeePainter(
color: color,
flapAmount: flapAmount,
eyeProgress: eyeProgress,
),
),
);
}
}
class _FlappingBeePainter extends CustomPainter {
final Color color;
final double flapAmount;
final double? eyeProgress;
const _FlappingBeePainter({
required this.color,
required this.flapAmount,
this.eyeProgress,
});
@override
void paint(Canvas canvas, Size size) {
final scale = min(size.width / 466, size.height / 309);
final renderedWidth = 466 * scale;
final renderedHeight = 309 * scale;
canvas
..save()
..translate(
(size.width - renderedWidth) / 2,
(size.height - renderedHeight) / 2,
)
..scale(scale);
final wingRadiusX = 91.7 * (1 - (0.38 * flapAmount));
final wingTranslation = 30 * flapAmount;
final leftWing = Path()
..addOval(
Rect.fromCenter(
center: Offset(91.7 + wingTranslation, 154.5),
width: wingRadiusX * 2,
height: 183.4,
),
);
final rightWing = Path()
..addOval(
Rect.fromCenter(
center: Offset(374.3 - wingTranslation, 154.5),
width: wingRadiusX * 2,
height: 183.4,
),
);
final body = Path()
..addRRect(
RRect.fromRectAndRadius(
const Rect.fromLTWH(128, 0, 210, 309),
const Radius.circular(34),
),
);
final cutouts = Path()
..addOval(
Rect.fromCenter(
center: const Offset(193.3, 84.4),
width: 54,
height: 54,
),
)
..addOval(
Rect.fromCenter(center: const Offset(276, 84.4), width: 54, height: 54),
)
..addRRect(
RRect.fromRectAndRadius(
const Rect.fromLTWH(166.3, 157.2, 136.9, 38.3),
const Radius.circular(5),
),
)
..addRRect(
RRect.fromRectAndRadius(
const Rect.fromLTWH(166.9, 235.1, 136.2, 37.6),
const Radius.circular(5),
),
);
final wings = Path.combine(PathOperation.union, leftWing, rightWing);
final silhouette = Path.combine(PathOperation.union, wings, body);
final finishedMark = Path.combine(
PathOperation.difference,
silhouette,
cutouts,
);
canvas.drawPath(finishedMark, Paint()..color = color);
if (eyeProgress case final progress?) {
final pupilRadius = 20 * progress.clamp(0.0, 1.0);
final pupilPaint = Paint()..color = color;
canvas
..drawCircle(const Offset(193.3, 84.4), pupilRadius, pupilPaint)
..drawCircle(const Offset(276, 84.4), pupilRadius, pupilPaint);
}
canvas.restore();
}
@override
bool shouldRepaint(_FlappingBeePainter oldDelegate) =>
color != oldDelegate.color ||
flapAmount != oldDelegate.flapAmount ||
eyeProgress != oldDelegate.eyeProgress;
}
@@ -0,0 +1,298 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../l10n/l10n.dart';
import '../theme/theme.dart';
import 'directional_transition_scope.dart';
/// Minimum height of the frosted app bar content area below the safe area.
const _kBarContentMinHeight = Grid.xxs + 32 + Grid.xxs; // 48
const _kBottomBorderWidth = 1.0;
TextStyle _effectiveTitleStyle(BuildContext context, TextStyle? titleStyle) {
final baseStyle =
context.textTheme.titleMedium ??
const TextStyle(fontSize: 20, height: 1.3);
return baseStyle.copyWith(fontWeight: FontWeight.w600).merge(titleStyle);
}
double _barContentHeight(
BuildContext context,
TextStyle? titleStyle,
double titleContentHeight,
) {
final style = _effectiveTitleStyle(context, titleStyle);
final scaledFontSize = MediaQuery.textScalerOf(
context,
).scale(style.fontSize ?? 20);
final scaledTitleHeight = scaledFontSize * (style.height ?? 1);
final effectiveTitleHeight = titleContentHeight > scaledTitleHeight
? titleContentHeight
: scaledTitleHeight;
final accessibleHeight = Grid.xxs + effectiveTitleHeight + Grid.xxs;
return accessibleHeight > _kBarContentMinHeight
? accessibleHeight
: _kBarContentMinHeight;
}
/// Height for a compact title rail below the app bar's action row.
///
/// The rail normally stays at 40dp, but grows with an accessible title rather
/// than clipping text at larger system text sizes.
double frostedAppBarLowerTitleHeight(
BuildContext context, {
TextStyle? titleStyle,
}) {
final style = _effectiveTitleStyle(context, titleStyle);
final scaledFontSize = MediaQuery.textScalerOf(
context,
).scale(style.fontSize ?? 20);
final titleHeight = scaledFontSize * (style.height ?? 1);
return titleHeight > 40 ? titleHeight : 40;
}
/// Returns the total height of the [FrostedAppBar] including safe area padding.
///
/// Use this to add top spacing to body content so it starts below the bar.
/// Pass the same [titleStyle] and [titleContentHeight] to the bar and this
/// helper when customizing them.
double frostedAppBarHeight(
BuildContext context, {
double bottomHeight = 0,
TextStyle? titleStyle,
double titleContentHeight = 0,
}) {
return MediaQuery.paddingOf(context).top +
_barContentHeight(context, titleStyle, titleContentHeight) +
bottomHeight +
_kBottomBorderWidth;
}
/// A frosted-glass floating app bar designed to sit inside a [Stack].
///
/// Renders as a [Positioned] widget pinned to the top of its parent Stack.
/// Content scrolls underneath with a translucent backdrop blur effect.
class FrostedAppBar extends StatelessWidget {
/// Widget displayed on the leading (left) side. If null and the navigator
/// can pop, a back button is shown automatically.
final Widget? leading;
/// Whether to infer a back button from the current navigator.
final bool automaticallyImplyLeading;
/// Widget displayed in the center/title area.
final Widget? title;
/// Optional style merged over the default title style.
final TextStyle? titleStyle;
/// Scaled height needed by a custom title with multiple text lines.
///
/// Pass the same value to [frostedAppBarHeight] when spacing body content.
final double titleContentHeight;
/// Optional content displayed below the title row in the same surface.
final Widget? bottom;
/// Height reserved for [bottom].
final double bottomHeight;
/// Extends [bottom] upward into the title row without moving the app bar's
/// outer bounds. This keeps overlapping controls inside the app bar's hit
/// test region as well as its paint region.
final double bottomOverlap;
/// Widgets displayed on the trailing (right) side.
final List<Widget> actions;
/// Horizontal inset for the app bar's leading, title, and actions.
final double horizontalInset;
/// Color applied to icons in the app bar.
final Color? iconColor;
/// Paints over the frosted fill instead of the default translucent surface.
/// Used by the Buzz themes to carry their branded gradient across the app's
/// top section — see [buzzTopSectionGradient].
final Gradient? gradient;
/// Whether to apply the translucent blur treatment behind the app bar.
///
/// A page can leave its painted backdrop exposed at rest, then turn this on
/// when scrolling moves content beneath the controls.
final bool frosted;
/// Opacity of the frosted surface above the blurred backdrop.
final double frostedSurfaceOpacity;
/// Blur strength of the frosted backdrop.
final double frostedBlurSigma;
/// Whether to draw a divider below the app bar.
final bool showBottomDivider;
/// Opacity of the divider below the app bar.
final double bottomDividerOpacity;
const FrostedAppBar({
super.key,
this.leading,
this.automaticallyImplyLeading = true,
this.title,
this.titleStyle,
this.titleContentHeight = 0,
this.bottom,
this.bottomHeight = 0,
this.bottomOverlap = 0,
this.actions = const [],
this.horizontalInset = Grid.quarter,
this.iconColor,
this.gradient,
this.frosted = true,
this.frostedSurfaceOpacity = 0.5,
this.frostedBlurSigma = 20,
this.showBottomDivider = true,
this.bottomDividerOpacity = 0.15,
}) : assert(bottom == null || bottomHeight > 0),
assert(bottomOverlap >= 0),
assert(bottom != null || bottomOverlap == 0),
assert(frostedBlurSigma >= 0),
assert(bottomDividerOpacity >= 0 && bottomDividerOpacity <= 1);
@override
Widget build(BuildContext context) {
final topPadding = MediaQuery.paddingOf(context).top;
final canPop = Navigator.canPop(context);
final effectiveTitleStyle = _effectiveTitleStyle(context, titleStyle);
final barContentHeight = _barContentHeight(
context,
titleStyle,
titleContentHeight,
);
final effectiveLeading =
leading ??
(automaticallyImplyLeading && canPop
? SizedBox(
width: 48,
height: 48,
child: IconButton(
onPressed: () => Navigator.of(context).pop(),
color: iconColor,
icon: const Icon(LucideIcons.chevronLeft),
tooltip: context.l10n.back,
),
)
: null);
final titleRow = SizedBox(
height: barContentHeight,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: horizontalInset),
child: IconTheme.merge(
data: IconThemeData(color: iconColor),
child: Row(
children: [
?effectiveLeading,
if (title != null)
Expanded(
child: Padding(
padding: EdgeInsets.only(
left: effectiveLeading != null
? 0
: horizontalInset < Grid.gutter
? Grid.gutter - horizontalInset
: 0,
right: actions.isEmpty
? horizontalInset < Grid.gutter
? Grid.gutter - horizontalInset
: 0
: 0,
),
child: DefaultTextStyle.merge(
style: effectiveTitleStyle,
overflow: TextOverflow.ellipsis,
maxLines: 1,
child: title!,
),
),
)
else
const Spacer(),
...actions,
],
),
),
),
);
final contentBody = bottom != null && bottomOverlap > 0
? SizedBox(
height: barContentHeight + bottomHeight,
child: Stack(
children: [
Positioned(top: 0, left: 0, right: 0, child: titleRow),
Positioned(
top: barContentHeight - bottomOverlap,
left: 0,
right: 0,
height: bottomHeight + bottomOverlap,
child: bottom!,
),
],
),
)
: Column(
mainAxisSize: MainAxisSize.min,
children: [
titleRow,
if (bottom != null) SizedBox(height: bottomHeight, child: bottom),
],
);
final content = DirectionalTransitionMotion(
transformKey: const ValueKey(
'frosted-app-bar-content-transition-transform',
),
opacityKey: const ValueKey('frosted-app-bar-content-transition-opacity'),
child: contentBody,
);
final background = Container(
key: const ValueKey('frosted-app-bar-background'),
padding: EdgeInsets.only(top: topPadding),
decoration: BoxDecoration(
color: !frosted
? Colors.transparent
: gradient == null
? context.colors.surface.withValues(alpha: frostedSurfaceOpacity)
: null,
gradient: gradient,
border: showBottomDivider
? Border(
bottom: BorderSide(
color: navigationDivider(context, bottomDividerOpacity),
width: _kBottomBorderWidth,
),
)
: null,
),
child: content,
);
final child = ClipRect(
child: frosted
? BackdropFilter(
filter: ImageFilter.blur(
sigmaX: frostedBlurSigma,
sigmaY: frostedBlurSigma,
),
child: background,
)
: background,
);
return Positioned(top: 0, left: 0, right: 0, child: child);
}
}
@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'directional_transition_scope.dart';
import 'frosted_app_bar.dart';
/// A convenience [Scaffold] that overlays a [FrostedAppBar] on top of its body.
///
/// The body is rendered full-bleed inside a [Stack] with the frosted app bar
/// floating above it. The body is responsible for adding its own top spacing
/// using [frostedAppBarHeight] so content starts below the bar.
class FrostedScaffold extends StatelessWidget {
/// The frosted app bar displayed at the top of the screen.
final FrostedAppBar appBar;
/// The primary content of the scaffold. Must handle its own top spacing
/// using [frostedAppBarHeight] — the scaffold does NOT add automatic padding.
final Widget body;
/// Optional floating action button, passed through to [Scaffold].
final Widget? floatingActionButton;
/// Whether the body should resize when the on-screen keyboard appears.
final bool? resizeToAvoidBottomInset;
/// Optional scaffold background, useful when a parent supplies a shared
/// surface behind this page.
final Color? backgroundColor;
/// A fixed gradient painted behind the app bar and scrolling body.
final Gradient? backgroundGradient;
const FrostedScaffold({
super.key,
required this.appBar,
required this.body,
this.floatingActionButton,
this.resizeToAvoidBottomInset,
this.backgroundColor,
this.backgroundGradient,
});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: backgroundColor,
resizeToAvoidBottomInset: resizeToAvoidBottomInset,
floatingActionButton: floatingActionButton,
body: Stack(children: _stackChildren()),
);
}
List<Widget> _stackChildren() {
final backdrop = backgroundGradient == null
? const <Widget>[]
: [
Positioned.fill(
child: _PinnedGradientBackground(gradient: backgroundGradient!),
),
];
final bodyMotion = DirectionalTransitionMotion(
transformKey: const ValueKey(
'frosted-scaffold-body-transition-transform',
),
opacityKey: const ValueKey('frosted-scaffold-body-transition-opacity'),
child: body,
);
// The bar must be painted after the scrollable sheet: [BackdropFilter]
// only samples pixels that were already painted behind it. This is the
// same composition as channel navigation, so top-level headers blur their
// content rather than only the fixed gradient.
return [...backdrop, bodyMotion, appBar];
}
}
class _PinnedGradientBackground extends StatelessWidget {
final Gradient gradient;
const _PinnedGradientBackground({required this.gradient});
@override
Widget build(BuildContext context) => DecoratedBox(
key: const ValueKey('frosted-scaffold-pinned-gradient'),
decoration: BoxDecoration(gradient: gradient),
);
}
@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
/// How far a finger must travel downward before the keyboard is dismissed.
///
/// Large enough that reading-speed scrolling never trips it, small enough that
/// a deliberate "push it away" swipe feels immediate.
const keyboardDismissDragThreshold = 48.0;
/// Dismiss the software keyboard when the user deliberately drags downward.
///
/// Flutter only offers `ScrollViewKeyboardDismissBehavior.manual` and `.onDrag`
/// — and `.onDrag` fires the instant a drag begins, so a one-pixel scroll to
/// re-read a message would tear the keyboard away. Neither is available on
/// `ScrollablePositionedList` regardless, which is what both message lists use.
///
/// So: accumulate raw finger travel and only unfocus once a downward drag
/// clears [keyboardDismissDragThreshold]. Any upward movement resets the
/// accumulator, so a scroll that wanders up and down never adds up to a
/// dismissal.
///
/// This tracks the *finger*, not the scroll direction, so it behaves the same
/// in the reversed channel timeline and the top-anchored thread list. It is not
/// interactive dismissal — the keyboard animates away on the OS's own curve
/// rather than following the finger, which Flutter can't do without a native
/// plugin (iOS `UIScrollView.keyboardDismissMode`, Android
/// `WindowInsetsAnimationController`).
class KeyboardDismissOnDrag extends HookWidget {
final Widget child;
final VoidCallback? onUserScrollStart;
const KeyboardDismissOnDrag({
super.key,
this.onUserScrollStart,
required this.child,
});
@override
Widget build(BuildContext context) {
// A ref, not state: accumulating travel must never trigger a rebuild of
// the message list this wraps.
final downwardTravel = useRef(0.0);
bool handle(ScrollNotification notification) {
if (notification is ScrollStartNotification) {
if (notification.dragDetails != null) onUserScrollStart?.call();
downwardTravel.value = 0;
return false;
}
if (notification is ScrollEndNotification) {
downwardTravel.value = 0;
return false;
}
// Overscroll counts as much as ordinary scrolling. You focus the composer
// at the resting end of the list far more often than not, and there a
// downward drag moves the position nowhere — it is pure overscroll, and
// reports `OverscrollNotification` instead. Listening only for
// `ScrollUpdateNotification` meant the most common dismissal gesture in
// both lists produced no notification this saw at all.
final DragUpdateDetails? drag;
if (notification is ScrollUpdateNotification) {
drag = notification.dragDetails;
} else if (notification is OverscrollNotification) {
drag = notification.dragDetails;
} else {
return false;
}
// Null during a momentum fling — only a finger still on the glass should
// dismiss, so a fling that coasts downward leaves the keyboard up.
if (drag == null) {
downwardTravel.value = 0;
return false;
}
final dy = drag.delta.dy;
if (dy <= 0) {
downwardTravel.value = 0;
return false;
}
downwardTravel.value += dy;
if (downwardTravel.value < keyboardDismissDragThreshold) return false;
downwardTravel.value = 0;
dismissKeyboard(context);
return false;
}
return NotificationListener<ScrollNotification>(
onNotification: handle,
child: child,
);
}
}
/// Drop focus so the OS retracts the keyboard. No-op when nothing is focused
/// or the keyboard is already down, so callers can fire it freely.
void dismissKeyboard(BuildContext context) {
// Read the view's own insets, not MediaQuery's. `Scaffold` strips the bottom
// view inset from its body while `resizeToAvoidBottomInset` is on — it has
// already resized to account for it — so a MediaQuery read from anywhere
// inside the body reports 0 whether the keyboard is up or not, which made
// this gate a permanent no-op for both message lists and the compose bar.
if (View.of(context).viewInsets.bottom <= 0) return;
FocusManager.instance.primaryFocus?.unfocus();
}
@@ -0,0 +1,356 @@
import 'dart:math' as math;
import 'package:flutter/widgets.dart';
/// Avatar-with-badge geometry ported from desktop's `MaskedAvatarBadgeFrame`
/// (`desktop/src/features/profile/ui/MaskedAvatarBadgeFrame.tsx`).
///
/// A badge (presence dot, status glyph) sits in a notch cut out of the avatar
/// rather than on top of it. The notch is not a plain circular subtraction: the
/// two edges are joined by cubic fillets so the avatar's outline flows into the
/// cutout instead of meeting it at a cusp. That fillet is the whole visual
/// signature, which is why the curve constants are ported verbatim.
@immutable
class AvatarBadgeCurve {
const AvatarBadgeCurve({
this.avatarRoundingAngle = 0.075,
this.cutoutRoundingLength = 5.5,
this.cutoutRoundingMinAngle = 0.22,
this.cutoutRoundingMaxAngle = 0.38,
this.handleDistanceRatio = 0.42,
this.handleLengthRatio = 0.14,
});
/// Radians the avatar's edge pulls back from the intersection before the
/// fillet starts.
final double avatarRoundingAngle;
/// Fillet length along the cutout, converted to an angle by dividing by the
/// cutout radius and clamped between the two angle bounds below.
final double cutoutRoundingLength;
final double cutoutRoundingMinAngle;
final double cutoutRoundingMaxAngle;
/// Bezier handle length, as a fraction of the gap between the two fillet
/// endpoints and of the cutout radius — whichever is shorter wins.
final double handleDistanceRatio;
final double handleLengthRatio;
/// Desktop's `DEFAULT_AVATAR_BADGE_CURVE`, used for large badges.
static const standard = AvatarBadgeCurve();
/// Desktop's `STATUS_DOT_MASK_CURVE` — a rounder, softer notch that reads
/// correctly at presence-dot sizes.
static const statusDot = AvatarBadgeCurve(
avatarRoundingAngle: 0.11,
cutoutRoundingLength: 6.5,
cutoutRoundingMinAngle: 0.28,
cutoutRoundingMaxAngle: 0.44,
handleDistanceRatio: 0.5,
handleLengthRatio: 0.2,
);
@override
bool operator ==(Object other) =>
other is AvatarBadgeCurve &&
other.avatarRoundingAngle == avatarRoundingAngle &&
other.cutoutRoundingLength == cutoutRoundingLength &&
other.cutoutRoundingMinAngle == cutoutRoundingMinAngle &&
other.cutoutRoundingMaxAngle == cutoutRoundingMaxAngle &&
other.handleDistanceRatio == handleDistanceRatio &&
other.handleLengthRatio == handleLengthRatio;
@override
int get hashCode => Object.hash(
avatarRoundingAngle,
cutoutRoundingLength,
cutoutRoundingMinAngle,
cutoutRoundingMaxAngle,
handleDistanceRatio,
handleLengthRatio,
);
}
/// Where the notch sits and how big the badge in it is, as fractions of the
/// avatar box so one constant covers every avatar size.
@immutable
class AvatarBadgeMaskGeometry {
const AvatarBadgeMaskGeometry({
required this.cutoutCenterRatio,
required this.cutoutRadiusRatio,
required this.badgeSizeRatio,
required this.curve,
});
/// Distance of the notch centre from the top-left, on both axes.
final double cutoutCenterRatio;
final double cutoutRadiusRatio;
/// The badge is centred on the notch and is deliberately smaller than it, so
/// a ring of background shows between badge and avatar.
final double badgeSizeRatio;
final AvatarBadgeCurve curve;
/// Desktop's settings-card treatment: a 192px avatar with a 54px badge notched
/// in at (165, 165) with radius 30.
static const badge = AvatarBadgeMaskGeometry(
cutoutCenterRatio: 165 / 192,
cutoutRadiusRatio: 30 / 192,
badgeSizeRatio: 54 / 192,
curve: AvatarBadgeCurve.standard,
);
/// Desktop's sidebar presence treatment: a 32px avatar with a 14px dot frame
/// notched in at (28, 28) with radius 7.5.
static const presenceDot = AvatarBadgeMaskGeometry(
cutoutCenterRatio: 28 / 32,
cutoutRadiusRatio: 7.5 / 32,
badgeSizeRatio: 14 / 32,
curve: AvatarBadgeCurve.statusDot,
);
Offset cutoutCenter(double size) =>
Offset(size * cutoutCenterRatio, size * cutoutCenterRatio);
double cutoutRadius(double size) => size * cutoutRadiusRatio;
double badgeSize(double size) => size * badgeSizeRatio;
@override
bool operator ==(Object other) =>
other is AvatarBadgeMaskGeometry &&
other.cutoutCenterRatio == cutoutCenterRatio &&
other.cutoutRadiusRatio == cutoutRadiusRatio &&
other.badgeSizeRatio == badgeSizeRatio &&
other.curve == curve;
@override
int get hashCode =>
Object.hash(cutoutCenterRatio, cutoutRadiusRatio, badgeSizeRatio, curve);
}
double _angleTo(Offset center, Offset point) =>
math.atan2(point.dy - center.dy, point.dx - center.dx);
Offset _pointOn(Offset center, double radius, double angle) =>
center + Offset(math.cos(angle), math.sin(angle)) * radius;
/// Unit tangent at [angle]; [clockwise] picks which way around the circle.
Offset _tangent(double angle, {required bool clockwise}) => clockwise
? Offset(-math.sin(angle), math.cos(angle))
: Offset(math.sin(angle), -math.cos(angle));
/// Signed sweep from [startAngle] to [endAngle], ported from desktop's
/// `getArcSweep`. Flutter shares SVG's angle convention (y grows downward, so a
/// positive sweep runs clockwise on screen), which is why the port is direct.
double _arcSweep(
double startAngle,
double endAngle, {
required bool clockwise,
required bool largeArc,
}) {
const fullTurn = math.pi * 2;
var sweep = clockwise ? endAngle - startAngle : startAngle - endAngle;
while (sweep < 0) {
sweep += fullTurn;
}
if (largeArc && sweep < math.pi) sweep += fullTurn;
if (!largeArc && sweep > math.pi) sweep -= fullTurn;
return clockwise ? sweep : -sweep;
}
/// The avatar outline with [geometry]'s notch cut out of its bottom-right.
///
/// Falls back to a plain circle when the notch does not actually cross the
/// avatar's edge — there is nothing to fillet in that case.
Path avatarBadgeMaskPath({
required double size,
required AvatarBadgeMaskGeometry geometry,
}) {
final avatarRadius = size / 2;
final avatarCenter = Offset(avatarRadius, avatarRadius);
final avatarRect = Rect.fromCircle(
center: avatarCenter,
radius: avatarRadius,
);
final cutoutCenter = geometry.cutoutCenter(size);
final cutoutRadius = geometry.cutoutRadius(size);
final curve = geometry.curve;
final span = cutoutCenter - avatarCenter;
final distance = span.distance;
if (distance >= avatarRadius + cutoutRadius ||
distance <= (avatarRadius - cutoutRadius).abs()) {
return Path()..addOval(avatarRect);
}
// Chord where the two circles cross, split into the upper and lower crossing.
final distanceToMidpoint =
(avatarRadius * avatarRadius -
cutoutRadius * cutoutRadius +
distance * distance) /
(2 * distance);
final halfChord = math.sqrt(
math.max(
0,
avatarRadius * avatarRadius - distanceToMidpoint * distanceToMidpoint,
),
);
final unit = span / distance;
final midpoint = avatarCenter + unit * distanceToMidpoint;
final perpendicular = Offset(-unit.dy, unit.dx);
final first = midpoint + perpendicular * halfChord;
final second = midpoint - perpendicular * halfChord;
final upperCrossing = first.dy < second.dy ? first : second;
final lowerCrossing = first.dy < second.dy ? second : first;
final cutoutRoundingAngle = math.min(
curve.cutoutRoundingMaxAngle,
math.max(
curve.cutoutRoundingMinAngle,
curve.cutoutRoundingLength / cutoutRadius,
),
);
// Pull each edge back from its crossing; the gap left behind is the fillet.
final avatarUpperAngle =
_angleTo(avatarCenter, upperCrossing) - curve.avatarRoundingAngle;
final avatarLowerAngle =
_angleTo(avatarCenter, lowerCrossing) + curve.avatarRoundingAngle;
final cutoutUpperAngle =
_angleTo(cutoutCenter, upperCrossing) - cutoutRoundingAngle;
final cutoutLowerAngle =
_angleTo(cutoutCenter, lowerCrossing) + cutoutRoundingAngle;
final avatarUpper = _pointOn(avatarCenter, avatarRadius, avatarUpperAngle);
final avatarLower = _pointOn(avatarCenter, avatarRadius, avatarLowerAngle);
final cutoutUpper = _pointOn(cutoutCenter, cutoutRadius, cutoutUpperAngle);
final cutoutLower = _pointOn(cutoutCenter, cutoutRadius, cutoutLowerAngle);
final upperHandle = math.min(
cutoutRadius * curve.handleLengthRatio,
(cutoutUpper - avatarUpper).distance * curve.handleDistanceRatio,
);
final lowerHandle = math.min(
cutoutRadius * curve.handleLengthRatio,
(avatarLower - cutoutLower).distance * curve.handleDistanceRatio,
);
final upperFromCutout =
cutoutUpper + _tangent(cutoutUpperAngle, clockwise: true) * upperHandle;
final upperIntoAvatar =
avatarUpper - _tangent(avatarUpperAngle, clockwise: false) * upperHandle;
final lowerFromAvatar =
avatarLower + _tangent(avatarLowerAngle, clockwise: false) * lowerHandle;
final lowerIntoCutout =
cutoutLower - _tangent(cutoutLowerAngle, clockwise: true) * lowerHandle;
return Path()
..moveTo(cutoutUpper.dx, cutoutUpper.dy)
..cubicTo(
upperFromCutout.dx,
upperFromCutout.dy,
upperIntoAvatar.dx,
upperIntoAvatar.dy,
avatarUpper.dx,
avatarUpper.dy,
)
// The long way round the avatar, back to the other side of the notch.
..arcTo(
avatarRect,
avatarUpperAngle,
_arcSweep(
avatarUpperAngle,
avatarLowerAngle,
clockwise: false,
largeArc: true,
),
false,
)
..cubicTo(
lowerFromAvatar.dx,
lowerFromAvatar.dy,
lowerIntoCutout.dx,
lowerIntoCutout.dy,
cutoutLower.dx,
cutoutLower.dy,
)
// Back across the notch itself, which bites into the avatar.
..arcTo(
Rect.fromCircle(center: cutoutCenter, radius: cutoutRadius),
cutoutLowerAngle,
_arcSweep(
cutoutLowerAngle,
cutoutUpperAngle,
clockwise: true,
largeArc: false,
),
false,
)
..close();
}
/// Clips an avatar to the rounded badge-notch path described by [geometry].
class AvatarBadgeMaskClipper extends CustomClipper<Path> {
const AvatarBadgeMaskClipper({required this.geometry});
final AvatarBadgeMaskGeometry geometry;
@override
Path getClip(Size size) =>
avatarBadgeMaskPath(size: size.shortestSide, geometry: geometry);
@override
bool shouldReclip(covariant AvatarBadgeMaskClipper oldClipper) =>
oldClipper.geometry != geometry;
}
/// A square [avatar] with [badge] seated in a notch cut out of its bottom-right
/// corner. With no [badge] the avatar is left whole — there is nothing to make
/// room for.
class MaskedAvatarBadge extends StatelessWidget {
const MaskedAvatarBadge({
super.key,
required this.size,
required this.avatar,
this.geometry = AvatarBadgeMaskGeometry.badge,
this.badge,
});
final double size;
final Widget avatar;
final AvatarBadgeMaskGeometry geometry;
final Widget? badge;
@override
Widget build(BuildContext context) {
if (badge == null) {
return SizedBox.square(dimension: size, child: avatar);
}
final badgeSize = geometry.badgeSize(size);
final center = geometry.cutoutCenter(size);
return SizedBox.square(
dimension: size,
child: Stack(
// The notch reaches past the avatar box, so the badge does too.
clipBehavior: Clip.none,
children: [
ClipPath(
clipper: AvatarBadgeMaskClipper(geometry: geometry),
child: SizedBox.square(dimension: size, child: avatar),
),
Positioned(
left: center.dx - badgeSize / 2,
top: center.dy - badgeSize / 2,
width: badgeSize,
height: badgeSize,
child: badge!,
),
],
),
);
}
}
@@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import '../theme/theme.dart';
/// A consistent inline author row for message-oriented surfaces.
class MessageAuthorMeta extends StatelessWidget {
/// Primary author name shown at the start of the row.
final String displayName;
/// Optional secondary username, hidden when blank or equal to [displayName].
final String? username;
/// Timestamp label shown after the author metadata.
final String timestamp;
/// Color applied to [displayName].
final Color nameColor;
/// Color applied to the username, separator, and [timestamp].
final Color metadataColor;
/// Optional callback invoked when [displayName] is tapped.
final VoidCallback? onAuthorTap;
/// Optional key assigned to the display-name text.
final Key? displayNameKey;
/// Optional key assigned to the username text.
final Key? usernameKey;
/// Optional key assigned to the timestamp text.
final Key? timestampKey;
/// Base text style for [displayName], with [nameColor] applied.
final TextStyle nameStyle;
/// Base text style for secondary metadata, with [metadataColor] applied.
final TextStyle metadataStyle;
/// Creates an inline author row with optional username and tap handling.
const MessageAuthorMeta({
super.key,
required this.displayName,
required this.timestamp,
required this.nameColor,
required this.metadataColor,
this.username,
this.onAuthorTap,
this.displayNameKey,
this.usernameKey,
this.timestampKey,
this.nameStyle = messageUsernameTextStyle,
this.metadataStyle = messageMetadataTextStyle,
});
@override
Widget build(BuildContext context) {
final normalizedUsername = username?.trim();
final showUsername =
normalizedUsername != null &&
normalizedUsername.isNotEmpty &&
normalizedUsername != displayName.trim();
final resolvedNameStyle = nameStyle.copyWith(color: nameColor);
final resolvedMetadataStyle = metadataStyle.copyWith(color: metadataColor);
Widget authorName = Text(
displayName,
key: displayNameKey,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: resolvedNameStyle,
);
if (onAuthorTap != null) {
authorName = GestureDetector(onTap: onAuthorTap, child: authorName);
}
return LayoutBuilder(
builder: (context, constraints) {
final metadataMaxWidth = constraints.hasBoundedWidth
? constraints.maxWidth / (showUsername ? 3 : 2)
: double.infinity;
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(child: authorName),
if (showUsername) ...[
const SizedBox(width: Grid.half),
ConstrainedBox(
constraints: BoxConstraints(maxWidth: metadataMaxWidth),
child: Text(
normalizedUsername,
key: usernameKey,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: resolvedMetadataStyle,
),
),
],
const SizedBox(width: Grid.half),
Text('·', style: resolvedMetadataStyle),
const SizedBox(width: Grid.half),
ConstrainedBox(
constraints: BoxConstraints(maxWidth: metadataMaxWidth),
child: Text(
timestamp,
key: timestampKey,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: resolvedMetadataStyle,
),
),
],
);
},
);
}
}
@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import '../theme/theme.dart';
/// Height of the floating mobile tab bar, excluding its bottom clearance.
const mobileTabBarHeight = 56.0;
/// Gap between the floating mobile tab bar and the bottom safe area.
const mobileTabBarBottomGap = Grid.twelve;
/// Fixed visual height of the shared footer fade behind the floating tab bar.
double mobileTabFooterBackdropHeight(BuildContext _) => 180;
/// Builds the shared transparent-to-surface footer fade.
///
/// Kept separate from [MobileTabFooterBackdrop] so floating controls such as
/// the channel composer can paint the exact same fade behind their own content.
LinearGradient mobileTabFooterBackdropGradient(
BuildContext context, {
List<double> stops = const [0, 0.18, 0.38, 0.6, 0.8, 1],
List<double> opacities = const [0, 0.03, 0.12, 0.34, 0.7, 1],
Color? tint,
double tintBlend = 0,
}) {
assert(stops.length == opacities.length);
assert(tintBlend >= 0 && tintBlend <= 1);
final surface = Color.lerp(context.colors.surface, tint, tintBlend)!;
return LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: stops,
colors: [
for (final opacity in opacities) surface.withValues(alpha: opacity),
],
);
}
/// Shared fade behind the floating mobile tab bar.
class MobileTabFooterBackdrop extends StatelessWidget {
/// Vertical extent of the backdrop in logical pixels.
final double height;
/// Gradient stop positions, from the transparent top to the opaque bottom.
final List<double> stops;
/// Surface-color alpha values paired with [stops].
final List<double> opacities;
/// Optional color blended into the surface before opacity is applied.
final Color? tint;
/// Amount of [tint] mixed into the surface, from 0 to 1.
final double tintBlend;
/// Creates a footer backdrop with the required [height].
///
/// Override [stops] and [opacities] together to customize the gradient.
const MobileTabFooterBackdrop({
super.key,
required this.height,
this.stops = const [0, 0.18, 0.38, 0.6, 0.8, 1],
this.opacities = const [0, 0.03, 0.12, 0.34, 0.7, 1],
this.tint,
this.tintBlend = 0,
}) : assert(stops.length == opacities.length),
assert(tintBlend >= 0 && tintBlend <= 1);
@override
Widget build(BuildContext context) {
return SizedBox(
height: height,
width: double.infinity,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: mobileTabFooterBackdropGradient(
context,
stops: stops,
opacities: opacities,
tint: tint,
tintBlend: tintBlend,
),
),
),
);
}
}
@@ -0,0 +1,221 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../l10n/l10n.dart';
import '../theme/theme.dart';
import 'concentric_sheet_surface.dart';
/// Shared motion for occasional modal UI.
///
/// The strong ease-out makes entrances respond immediately, while the shorter
/// exit keeps dismissals from feeling sluggish.
const buzzModalAnimationStyle = AnimationStyle(
curve: Cubic(0.23, 1, 0.32, 1),
duration: Duration(milliseconds: 280),
reverseCurve: Cubic(0.77, 0, 0.175, 1),
reverseDuration: Duration(milliseconds: 220),
);
/// Shows a bottom sheet with Buzz's shared motion and sheet chrome.
///
/// Sheets include the shared close control by default. On iOS, the surface
/// uses native concentric corners when available and paints a requested drag
/// handle inside that inset surface; other platforms retain Flutter's handle.
Future<T?> showBuzzModalBottomSheet<T>({
required BuildContext context,
required WidgetBuilder builder,
Color? backgroundColor,
String? barrierLabel,
double? elevation,
ShapeBorder? shape,
Clip? clipBehavior,
BoxConstraints? constraints,
Color? barrierColor,
bool isScrollControlled = false,
double scrollControlDisabledMaxHeightRatio = 9.0 / 16.0,
bool useRootNavigator = false,
bool isDismissible = true,
bool enableDrag = true,
bool? showDragHandle,
bool showCloseButton = true,
bool useSafeArea = false,
RouteSettings? routeSettings,
AnimationController? transitionAnimationController,
Offset? anchorPoint,
AnimationStyle? sheetAnimationStyle,
bool? requestFocus,
}) {
final isIos = defaultTargetPlatform == TargetPlatform.iOS;
final theme = Theme.of(context);
final surfaceColor =
backgroundColor ??
theme.bottomSheetTheme.modalBackgroundColor ??
theme.bottomSheetTheme.backgroundColor ??
context.colors.surface;
final reduceMotion = MediaQuery.disableAnimationsOf(context);
return showModalBottomSheet<T>(
context: context,
builder: (sheetContext) => ConcentricSheetSurface(
enabled: isIos,
color: surfaceColor,
child: _SheetContent(
showCloseButton: showCloseButton,
showDragHandle: isIos && showDragHandle == true,
child: builder(sheetContext),
),
),
backgroundColor: isIos ? Colors.transparent : backgroundColor,
barrierLabel: barrierLabel,
elevation: elevation,
shape: shape,
clipBehavior: clipBehavior,
constraints: constraints,
barrierColor: barrierColor,
isScrollControlled: isScrollControlled,
scrollControlDisabledMaxHeightRatio: scrollControlDisabledMaxHeightRatio,
useRootNavigator: useRootNavigator,
isDismissible: isDismissible,
enableDrag: enableDrag,
// The iOS route is transparent so its stock handle sits outside the inset
// concentric surface. Paint it inside the surface above instead.
showDragHandle: isIos ? false : showDragHandle,
useSafeArea: useSafeArea,
routeSettings: routeSettings,
transitionAnimationController: transitionAnimationController,
anchorPoint: anchorPoint,
sheetAnimationStyle: reduceMotion
? AnimationStyle.noAnimation
: (sheetAnimationStyle ?? buzzModalAnimationStyle),
requestFocus: requestFocus,
);
}
class _SheetContent extends StatelessWidget {
const _SheetContent({
required this.child,
required this.showCloseButton,
required this.showDragHandle,
});
final Widget child;
final bool showCloseButton;
final bool showDragHandle;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (showCloseButton)
Padding(
padding: const EdgeInsets.only(
top: Grid.xxs,
left: Grid.gutter,
right: Grid.gutter,
bottom: Grid.xs,
),
child: SizedBox(
height: 56,
child: Stack(
alignment: Alignment.topCenter,
children: [
if (showDragHandle) const _SheetDragHandle(),
Align(
alignment: Alignment.bottomRight,
child: SizedBox.square(
dimension: 44,
child: IconButton(
tooltip: context.l10n.closeSheet,
onPressed: () {
unawaited(HapticFeedback.lightImpact());
Navigator.of(context).pop();
},
style: IconButton.styleFrom(
padding: EdgeInsets.zero,
backgroundColor:
context.colors.surfaceContainerHighest,
foregroundColor: context.colors.onSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.dialog),
),
),
icon: const Icon(LucideIcons.x, size: 22),
),
),
),
],
),
),
)
else if (showDragHandle)
const Padding(
padding: EdgeInsets.only(top: Grid.xxs, bottom: Grid.xs),
child: _SheetDragHandle(),
),
Flexible(child: child),
],
);
}
}
class _SheetDragHandle extends StatelessWidget {
const _SheetDragHandle();
@override
Widget build(BuildContext context) {
return Semantics(
label: context.l10n.dragHandle,
child: Container(
key: const ValueKey('buzz-sheet-drag-handle'),
width: 32,
height: 4,
decoration: BoxDecoration(
color: context.colors.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(Radii.full),
),
),
);
}
}
/// Shows a dialog with Buzz's shared motion, respecting reduced-motion settings.
Future<T?> showBuzzDialog<T>({
required BuildContext context,
required WidgetBuilder builder,
bool barrierDismissible = true,
Color? barrierColor,
String? barrierLabel,
bool useSafeArea = true,
bool useRootNavigator = true,
RouteSettings? routeSettings,
Offset? anchorPoint,
TraversalEdgeBehavior? traversalEdgeBehavior,
bool fullscreenDialog = false,
bool? requestFocus,
AnimationStyle? animationStyle,
}) {
final reduceMotion = MediaQuery.disableAnimationsOf(context);
return showDialog<T>(
context: context,
builder: builder,
barrierDismissible: barrierDismissible,
barrierColor: barrierColor,
barrierLabel: barrierLabel,
useSafeArea: useSafeArea,
useRootNavigator: useRootNavigator,
routeSettings: routeSettings,
anchorPoint: anchorPoint,
traversalEdgeBehavior: traversalEdgeBehavior,
fullscreenDialog: fullscreenDialog,
requestFocus: requestFocus,
animationStyle: reduceMotion
? AnimationStyle.noAnimation
: (animationStyle ?? buzzModalAnimationStyle),
);
}
@@ -0,0 +1,17 @@
import 'package:flutter/material.dart';
import '../theme/theme.dart';
/// Hairline divider between action groups in a bottom sheet, matching the
/// `AppListSection` divider convention.
class SheetDivider extends StatelessWidget {
const SheetDivider({super.key});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
child: Divider(height: 1, color: context.colors.outlineVariant),
);
}
}
+17
View File
@@ -0,0 +1,17 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import '../theme/theme.dart';
part 'skeleton_bar.dart';
part 'skeleton_mask.dart';
part 'skeleton_reveal.dart';
part 'skeleton_shimmer.dart';
const _shimmerDuration = Duration(milliseconds: 2000);
const _skeletonOpacity = 0.1;
const _shimmerMinOpacity = 0.5;
const _revealDuration = Duration(milliseconds: 400);
const _revealBlur = 2.0;
@@ -0,0 +1,33 @@
part of 'skeleton.dart';
/// A single rounded placeholder within a [SkeletonShimmer].
class SkeletonBar extends StatelessWidget {
final double width;
final double height;
final BorderRadius? borderRadius;
const SkeletonBar({
required this.width,
required this.height,
this.borderRadius,
super.key,
});
@override
Widget build(BuildContext context) {
return ExcludeSemantics(
child: SizedBox(
width: width,
height: height,
child: DecoratedBox(
decoration: BoxDecoration(
color: _SkeletonMask.isActive(context)
? Colors.white
: context.colors.primary.withValues(alpha: _skeletonOpacity),
borderRadius: borderRadius ?? BorderRadius.circular(Radii.sm),
),
),
),
);
}
}
@@ -0,0 +1,11 @@
part of 'skeleton.dart';
class _SkeletonMask extends InheritedWidget {
const _SkeletonMask({required super.child});
static bool isActive(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<_SkeletonMask>() != null;
@override
bool updateShouldNotify(_SkeletonMask oldWidget) => false;
}
@@ -0,0 +1,103 @@
part of 'skeleton.dart';
/// Stacks a skeleton and its content in the same slot, then reveals the content.
///
/// Entering the loading state is intentionally instant so reconnects do not
/// animate backwards. Leaving it cross-fades both layers while the skeleton
/// blurs out and the content sharpens over 400ms.
class SkeletonReveal extends HookWidget {
final bool loading;
final Widget skeleton;
final Widget content;
final bool shimmerEnabled;
const SkeletonReveal({
required this.loading,
required this.skeleton,
required this.content,
this.shimmerEnabled = true,
super.key,
});
@override
Widget build(BuildContext context) {
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final reveal = useAnimationController(
duration: _revealDuration,
initialValue: loading ? 0 : 1,
);
final previousLoading = usePrevious(loading);
useEffect(() {
if (reducedMotion || previousLoading == null) {
reveal
..stop()
..value = loading ? 0 : 1;
} else if (loading) {
// Reset directly to the loading state. Only the reveal animates.
reveal
..stop()
..value = 0;
} else if (previousLoading) {
reveal.forward(from: 0);
}
return null;
}, [loading, reducedMotion]);
return AnimatedBuilder(
animation: reveal,
builder: (context, _) {
final progress = reveal.value;
final skeletonBlur = reducedMotion ? 0.0 : _revealBlur * progress;
final contentBlur = reducedMotion ? 0.0 : _revealBlur * (1 - progress);
return Stack(
fit: StackFit.expand,
children: [
Opacity(
key: const Key('skeleton-reveal-content'),
opacity: progress,
child: ImageFiltered(
enabled: contentBlur > 0.01,
imageFilter: ImageFilter.blur(
sigmaX: contentBlur,
sigmaY: contentBlur,
),
child: IgnorePointer(
ignoring: loading || progress < 1,
child: ExcludeFocus(
excluding: loading || progress < 1,
child: ExcludeSemantics(
excluding: loading || progress < 1,
child: content,
),
),
),
),
),
Opacity(
key: const Key('skeleton-reveal-placeholder'),
opacity: 1 - progress,
child: ImageFiltered(
enabled: skeletonBlur > 0.01,
imageFilter: ImageFilter.blur(
sigmaX: skeletonBlur,
sigmaY: skeletonBlur,
),
child: IgnorePointer(
child: ExcludeSemantics(
excluding: !loading,
child: SkeletonShimmer(
enabled: loading && shimmerEnabled,
child: skeleton,
),
),
),
),
),
],
);
},
);
}
}
@@ -0,0 +1,71 @@
part of 'skeleton.dart';
/// Sweeps one shared highlight across a group of skeleton elements.
///
/// This mirrors desktop's two-second linear shimmer. Reduced-motion users see
/// the same element shapes without the moving highlight.
class SkeletonShimmer extends HookWidget {
final Widget child;
final bool enabled;
const SkeletonShimmer({required this.child, this.enabled = true, super.key});
@override
Widget build(BuildContext context) {
final animation = useAnimationController(duration: _shimmerDuration);
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final colors = context.colors;
final baseColor = colors.primary.withValues(alpha: _skeletonOpacity);
final highlightColor = colors.primary.withValues(
alpha: _skeletonOpacity * _shimmerMinOpacity,
);
useEffect(() {
if (reducedMotion || !enabled) {
animation
..stop()
..value = 0;
} else {
animation.repeat();
}
return animation.stop;
}, [animation, enabled, reducedMotion]);
if (reducedMotion || !enabled) {
return _SkeletonMask(
child: ColorFiltered(
colorFilter: ColorFilter.mode(baseColor, BlendMode.srcIn),
child: child,
),
);
}
return _SkeletonMask(
child: RepaintBoundary(
child: AnimatedBuilder(
animation: animation,
child: child,
builder: (context, child) {
final center = -1.5 + (animation.value * 3);
return ShaderMask(
blendMode: BlendMode.srcIn,
shaderCallback: (bounds) => LinearGradient(
begin: Alignment(center - 1, 0),
end: Alignment(center + 1, 0),
colors: [
baseColor,
baseColor,
highlightColor,
baseColor,
baseColor,
],
stops: const [0, 0.34, 0.5, 0.66, 1],
).createShader(bounds),
child: child,
);
},
),
),
);
}
}
@@ -0,0 +1,64 @@
import 'dart:math' show cos, pi;
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../l10n/l10n.dart';
import 'flapping_bee.dart';
/// The Buzz mark with wings that flutter twice when the user taps it.
///
/// The geometry and wing tuck match the desktop loading bee. When reduced
/// motion is enabled, the mark stays static.
class TappableFlappingBee extends HookConsumerWidget {
/// The rendered width of the complete bee mark.
final double width;
/// The color used for the bee silhouette.
final Color color;
const TappableFlappingBee({
required this.width,
required this.color,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final animation = useAnimationController(
duration: const Duration(milliseconds: 480),
);
final reducedMotion = MediaQuery.disableAnimationsOf(context);
void flutterWings() {
if (reducedMotion) return;
animation.forward(from: 0);
}
return Semantics(
button: true,
label: context.l10n.buzzBee,
hint: context.l10n.buzzBeeHint,
onTap: flutterWings,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
excludeFromSemantics: true,
onTap: flutterWings,
child: RepaintBoundary(
child: AnimatedBuilder(
animation: animation,
builder: (context, _) {
final flapAmount = 0.5 - (0.5 * cos(animation.value * 4 * pi));
return FlappingBee(
width: width,
color: color,
flapAmount: flapAmount,
);
},
),
),
),
);
}
}