fix(mobile): 6 bug fixes — QR, resume, bots, typing, errors, keyboard (#397)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-24 10:16:34 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 30adbad810
commit 353729a56d
9 changed files with 230 additions and 37 deletions
@@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA" />
<application
android:label="Sprout"
android:name="${applicationName}"
+2
View File
@@ -26,6 +26,8 @@
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Sprout needs camera access to scan QR codes for device pairing.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Sprout needs photo library access so you can attach images to messages.</string>
<key>UIApplicationSceneManifest</key>
@@ -59,15 +59,20 @@ class ChannelDetailPage extends HookConsumerWidget {
final detailsAsync = ref.watch(channelDetailsProvider(channel.id));
final channelsAsync = ref.watch(channelsProvider);
final messagesState = ref.watch(channelMessagesProvider(channel.id));
// Only show channel-level typing (exclude thread-scoped entries).
final typingEntries = ref
.watch(channelTypingProvider(channel.id))
.where((e) => e.threadHeadId == null)
.toList();
final currentPubkey = ref
.watch(profileProvider)
.whenData((value) => value?.pubkey)
.value;
// Only show channel-level typing (exclude thread-scoped entries and self).
final typingEntries = ref
.watch(channelTypingProvider(channel.id))
.where((e) => e.threadHeadId == null)
.where(
(e) =>
currentPubkey == null ||
e.pubkey.toLowerCase() != currentPubkey.toLowerCase(),
)
.toList();
final baseChannel =
channelsAsync
.whenData(
@@ -148,6 +153,9 @@ class ChannelDetailPage extends HookConsumerWidget {
),
body: Column(
children: [
_DetailConnectionBanner(
status: ref.watch(relaySessionProvider).status,
),
Expanded(
child: resolvedChannel.isForum
? ForumPostsView(
@@ -860,6 +868,53 @@ class _ReadOnlyNotice extends StatelessWidget {
}
}
// ---------------------------------------------------------------------------
// Connection banner (shown inside channel detail during reconnect)
// ---------------------------------------------------------------------------
class _DetailConnectionBanner extends StatelessWidget {
final SessionStatus status;
const _DetailConnectionBanner({required this.status});
@override
Widget build(BuildContext context) {
if (status == SessionStatus.connected ||
status == SessionStatus.disconnected) {
return const SizedBox.shrink();
}
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: Grid.xs,
vertical: Grid.quarter + 2,
),
color: context.colors.surfaceContainerHighest,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 2,
color: context.colors.onSurfaceVariant,
),
),
const SizedBox(width: Grid.xxs),
Text(
'Reconnecting…',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Typing indicator
// ---------------------------------------------------------------------------
@@ -9,9 +9,14 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
final String channelId;
void Function()? _unsubscribe;
bool _reachedOldest = false;
bool _initInFlight = false;
ChannelMessagesNotifier(this.channelId);
/// Last successfully loaded messages, preserved across reconnections so the
/// UI can show stale data instead of a blank loading spinner.
List<NostrEvent>? _lastKnownMessages;
@override
AsyncValue<List<NostrEvent>> build() {
final sessionState = ref.watch(relaySessionProvider);
@@ -21,16 +26,23 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
});
if (sessionState.status != SessionStatus.connected) {
return const AsyncData([]);
// Return cached messages if available so the UI remains usable while
// disconnected/reconnecting, instead of showing an empty screen.
return AsyncData(_lastKnownMessages ?? const []);
}
// Reset pagination state on rebuild (e.g. after reconnect).
_reachedOldest = false;
_init();
// Show previous messages while fetching fresh ones, instead of a spinner.
if (_lastKnownMessages case final cached? when cached.isNotEmpty) {
return AsyncData(cached);
}
return const AsyncLoading();
}
Future<void> _init() async {
_initInFlight = true;
try {
final session = ref.read(relaySessionProvider.notifier);
@@ -57,15 +69,31 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
_handleLiveEvent,
);
history.sort((a, b) => a.createdAt.compareTo(b.createdAt));
state = AsyncData(history);
// Merge fresh history with any events already in state (e.g. from
// fetchOlder() or live events that arrived while _init was in flight)
// to avoid discarding data the user has already scrolled through.
final existing = state.value ?? const [];
final existingIds = existing.map((e) => e.id).toSet();
final newEvents = history
.where((e) => !existingIds.contains(e.id))
.toList();
final merged = [...existing, ...newEvents];
merged.sort((a, b) => a.createdAt.compareTo(b.createdAt));
_lastKnownMessages = merged;
state = AsyncData(merged);
} catch (e, st) {
state = AsyncError(e, st);
} finally {
_initInFlight = false;
}
}
void _handleLiveEvent(NostrEvent event) {
state = state.whenData((events) => _mergeEvent(events, event));
state = state.whenData((events) {
final merged = _mergeEvent(events, event);
_lastKnownMessages = merged;
return merged;
});
// When a membership system event arrives, refresh the channel member list
// so the @mention autocomplete picks up new members without a restart.
@@ -98,7 +126,7 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
/// Fetch older messages (pagination). Call this when the user scrolls up.
/// Returns `true` if new messages were loaded.
Future<bool> fetchOlder() async {
if (_reachedOldest) return false;
if (_reachedOldest || _initInFlight) return false;
final currentEvents = state.value;
if (currentEvents == null || currentEvents.isEmpty) return false;
@@ -136,6 +164,7 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
state = state.whenData((events) {
final merged = [...deduped, ...events];
merged.sort((a, b) => a.createdAt.compareTo(b.createdAt));
_lastKnownMessages = merged;
return merged;
});
return true;
@@ -855,7 +855,10 @@ class _BrowseChannelsSheet extends HookConsumerWidget {
)
: ListView(
controller: scrollController,
padding: const EdgeInsets.only(top: Grid.xxs),
padding: EdgeInsets.only(
top: Grid.xxs,
bottom: MediaQuery.viewInsetsOf(context).bottom,
),
children: [
if (notJoined.isNotEmpty) ...[
_MiniHeader(
+62 -24
View File
@@ -24,6 +24,7 @@ class MembersSheet extends HookConsumerWidget {
final membersAsync = ref.watch(channelMembersProvider(channel.id));
final allMembers = membersAsync.asData?.value ?? const <ChannelMember>[];
final people = allMembers.where((member) => !member.isBot).toList();
final bots = allMembers.where((member) => member.isBot).toList();
final userCache = ref.watch(userCacheProvider);
// Determine if the current user can manage members.
@@ -38,13 +39,13 @@ class MembersSheet extends HookConsumerWidget {
// Preload profiles for all members so avatars appear.
useEffect(() {
if (people.isNotEmpty) {
if (allMembers.isNotEmpty) {
ref
.read(userCacheProvider.notifier)
.preload(people.map((m) => m.pubkey).toList());
.preload(allMembers.map((m) => m.pubkey).toList());
}
return null;
}, [people.length]);
}, [allMembers.length]);
return Padding(
padding: EdgeInsets.fromLTRB(
@@ -69,34 +70,50 @@ class MembersSheet extends HookConsumerWidget {
),
),
if (!channel.isDm) ...[const Divider(height: Grid.sm)],
SizedBox(
height: 280,
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 400),
child: membersAsync.when(
data: (_) => people.isEmpty
? Center(
data: (_) => ListView(
shrinkWrap: true,
children: [
if (people.isNotEmpty) ...[
_SectionLabel(label: 'People — ${people.length}'),
for (final member in people)
_MemberTile(
member: member,
currentPubkey: currentPubkey,
profile: userCache[member.pubkey.toLowerCase()],
canManage: canManage,
isSelf:
member.pubkey.toLowerCase() ==
currentPubkey?.toLowerCase(),
channelId: channel.id,
),
],
if (bots.isNotEmpty) ...[
const SizedBox(height: Grid.xxs),
_SectionLabel(label: 'Bots — ${bots.length}'),
for (final bot in bots)
_MemberTile(
member: bot,
currentPubkey: currentPubkey,
profile: userCache[bot.pubkey.toLowerCase()],
canManage: canManage,
isSelf: false,
channelId: channel.id,
),
],
if (people.isEmpty && bots.isEmpty)
Center(
child: Text(
'No people found.',
'No members found.',
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.outline,
),
),
)
: ListView(
shrinkWrap: true,
children: [
for (final member in people)
_MemberTile(
member: member,
currentPubkey: currentPubkey,
profile: userCache[member.pubkey.toLowerCase()],
canManage: canManage,
isSelf:
member.pubkey.toLowerCase() ==
currentPubkey?.toLowerCase(),
channelId: channel.id,
),
],
),
],
),
loading: () =>
const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
@@ -117,6 +134,27 @@ class MembersSheet extends HookConsumerWidget {
}
}
class _SectionLabel extends StatelessWidget {
final String label;
const _SectionLabel({required this.label});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: Grid.half, bottom: Grid.half),
child: Text(
label.toUpperCase(),
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.outline,
fontWeight: FontWeight.w600,
letterSpacing: 0.8,
),
),
);
}
}
const _changeableRoles = ['admin', 'member', 'guest'];
class _MemberTile extends ConsumerWidget {
@@ -58,10 +58,15 @@ class ThreadDetailPage extends HookConsumerWidget {
final replies = childrenByParent[threadHead.id] ?? const [];
// Thread-scoped typing indicators.
// Thread-scoped typing indicators (exclude self).
final allTyping = ref.watch(channelTypingProvider(channelId));
final threadTyping = allTyping
.where((e) => e.threadHeadId == threadHead.id)
.where(
(e) =>
currentPubkey == null ||
e.pubkey.toLowerCase() != currentPubkey?.toLowerCase(),
)
.toList();
// Resolve thread head from live data (reactions/edits may have changed).
@@ -319,6 +319,9 @@ class _ScannerPage extends HookWidget {
@override
Widget build(BuildContext context) {
final handled = useState(false);
final controller = useMemoized(() => MobileScannerController());
useEffect(() => controller.dispose, const []);
return Scaffold(
appBar: AppBar(
@@ -329,6 +332,38 @@ class _ScannerPage extends HookWidget {
),
),
body: MobileScanner(
controller: controller,
errorBuilder: (context, error) {
final message = switch (error.errorCode) {
MobileScannerErrorCode.permissionDenied =>
'Camera permission is required to scan QR codes.\n\nPlease grant camera access in your device settings.',
_ =>
'Could not start camera: ${error.errorDetails?.message ?? 'unknown error'}',
};
return Center(
child: Padding(
padding: const EdgeInsets.all(Grid.sm),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.cameraOff,
size: 48,
color: context.colors.onSurfaceVariant,
),
const SizedBox(height: Grid.xs),
Text(
message,
textAlign: TextAlign.center,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
),
);
},
onDetect: (capture) {
if (handled.value) return;
final barcodes = capture.barcodes;
@@ -229,14 +229,39 @@ class PairingNotifier extends Notifier<PairingState> {
errorMessage: 'Invalid pairing code: ${e.message}',
);
} catch (e) {
debugPrint('Pairing connection error: $e');
_cleanup();
state = PairingState(
status: PairingStatus.error,
errorMessage: 'Connection failed: $e',
errorMessage: _friendlyErrorMessage(e),
);
}
}
static String _friendlyErrorMessage(Object error) {
final message = error.toString();
if (message.contains('SocketException') ||
message.contains('Connection refused') ||
message.contains('Network is unreachable') ||
message.contains('No route to host') ||
message.contains('Failed to connect') ||
message.contains('Null check operator used on a null value')) {
return 'Could not reach the pairing relay. Check your internet '
'connection and VPN, then try again.';
}
if (message.contains('HandshakeException') ||
message.contains('CERTIFICATE_VERIFY_FAILED')) {
return 'Secure connection failed. Check your network settings '
'and try again.';
}
if (message.contains('TimeoutException') || message.contains('timed out')) {
return 'Connection timed out. Check your internet connection and '
'try again.';
}
return 'Connection failed. Please check your internet connection '
'and try again.';
}
void _handleRelayMessage(List<dynamic> data) {
if (data.isEmpty) return;
final type = data[0] as String;