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
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:
@@ -0,0 +1,4 @@
|
||||
export 'auth_provider.dart';
|
||||
export '../community/community.dart';
|
||||
export '../community/community_provider.dart';
|
||||
export '../community/community_storage.dart';
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 '';
|
||||
if (type.startsWith('image/')) return '';
|
||||
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;
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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)!);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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()))}';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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!;
|
||||
}
|
||||
}
|
||||
@@ -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(' ');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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('>', '>')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('&', '&');
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user