mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(mobile): add channel list with relay integration (#315)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
4
mobile/.env.json.example
Normal file
4
mobile/.env.json.example
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"SPROUT_RELAY_URL": "http://localhost:3000",
|
||||||
|
"SPROUT_DEV_PUBKEY": "<your-hex-pubkey-here>"
|
||||||
|
}
|
||||||
3
mobile/.gitignore
vendored
3
mobile/.gitignore
vendored
@@ -43,3 +43,6 @@ app.*.map.json
|
|||||||
/android/app/debug
|
/android/app/debug
|
||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
|
|
||||||
|
# Local environment config (contains keys)
|
||||||
|
.env.json
|
||||||
|
|||||||
54
mobile/lib/features/channels/channel.dart
Normal file
54
mobile/lib/features/channels/channel.dart
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class Channel {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String channelType; // "stream", "forum", "dm"
|
||||||
|
final String visibility; // "open", "private"
|
||||||
|
final String description;
|
||||||
|
final String? topic;
|
||||||
|
final String? purpose;
|
||||||
|
final String createdBy;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final int memberCount;
|
||||||
|
final DateTime? lastMessageAt;
|
||||||
|
final bool isMember;
|
||||||
|
|
||||||
|
const Channel({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.channelType,
|
||||||
|
required this.visibility,
|
||||||
|
required this.description,
|
||||||
|
required this.createdBy,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.memberCount,
|
||||||
|
this.topic,
|
||||||
|
this.purpose,
|
||||||
|
this.lastMessageAt,
|
||||||
|
this.isMember = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Channel.fromJson(Map<String, dynamic> json) => Channel(
|
||||||
|
id: json['id'] as String,
|
||||||
|
name: json['name'] as String,
|
||||||
|
channelType: json['channel_type'] as String,
|
||||||
|
visibility: json['visibility'] as String,
|
||||||
|
description: (json['description'] as String?) ?? '',
|
||||||
|
topic: json['topic'] as String?,
|
||||||
|
purpose: json['purpose'] as String?,
|
||||||
|
createdBy: json['created_by'] as String,
|
||||||
|
createdAt: DateTime.parse(json['created_at'] as String),
|
||||||
|
memberCount: json['member_count'] as int,
|
||||||
|
lastMessageAt: json['last_message_at'] != null
|
||||||
|
? DateTime.parse(json['last_message_at'] as String)
|
||||||
|
: null,
|
||||||
|
isMember: json['is_member'] as bool? ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
|
bool get isStream => channelType == 'stream';
|
||||||
|
bool get isForum => channelType == 'forum';
|
||||||
|
bool get isDm => channelType == 'dm';
|
||||||
|
bool get isPrivate => visibility == 'private';
|
||||||
|
}
|
||||||
222
mobile/lib/features/channels/channels_page.dart
Normal file
222
mobile/lib/features/channels/channels_page.dart
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
|
||||||
|
import '../../shared/relay/relay_client.dart';
|
||||||
|
import '../../shared/theme/theme.dart';
|
||||||
|
import '../profile/profile_avatar.dart';
|
||||||
|
import 'channel.dart';
|
||||||
|
import 'channels_provider.dart';
|
||||||
|
|
||||||
|
class ChannelsPage extends HookConsumerWidget {
|
||||||
|
const ChannelsPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final channelsAsync = ref.watch(channelsProvider);
|
||||||
|
|
||||||
|
// Poll every 30s while this page is mounted, matching desktop's pattern.
|
||||||
|
useEffect(() {
|
||||||
|
final timer = Timer.periodic(
|
||||||
|
const Duration(seconds: 30),
|
||||||
|
(_) => ref.read(channelsProvider.notifier).refresh(),
|
||||||
|
);
|
||||||
|
return timer.cancel;
|
||||||
|
}, const []);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Channels'),
|
||||||
|
actions: const [ProfileAvatar()],
|
||||||
|
),
|
||||||
|
body: channelsAsync.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (error, _) => _ErrorView(
|
||||||
|
error: error,
|
||||||
|
onRetry: () => ref.read(channelsProvider.notifier).refresh(),
|
||||||
|
),
|
||||||
|
data: (channels) => _ChannelsList(channels: channels),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChannelsList extends ConsumerWidget {
|
||||||
|
final List<Channel> channels;
|
||||||
|
|
||||||
|
const _ChannelsList({required this.channels});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () => ref.read(channelsProvider.notifier).refresh(),
|
||||||
|
child: channels.isEmpty
|
||||||
|
? ListView(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
height: MediaQuery.sizeOf(context).height * 0.6,
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
LucideIcons.hash,
|
||||||
|
size: Grid.xl,
|
||||||
|
color: context.colors.outline,
|
||||||
|
),
|
||||||
|
const SizedBox(height: Grid.xs),
|
||||||
|
Text(
|
||||||
|
'No channels yet',
|
||||||
|
style: context.textTheme.bodyLarge?.copyWith(
|
||||||
|
color: context.colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: ListView.separated(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
|
||||||
|
itemCount: channels.length,
|
||||||
|
separatorBuilder: (_, _) =>
|
||||||
|
const Divider(height: 1, indent: Grid.xl),
|
||||||
|
itemBuilder: (context, index) =>
|
||||||
|
_ChannelTile(channel: channels[index]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChannelTile extends StatelessWidget {
|
||||||
|
final Channel channel;
|
||||||
|
|
||||||
|
const _ChannelTile({required this.channel});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
_iconFor(channel),
|
||||||
|
color: channel.isMember
|
||||||
|
? context.colors.primary
|
||||||
|
: context.colors.outline,
|
||||||
|
),
|
||||||
|
title: Text(channel.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
|
subtitle: channel.description.isNotEmpty
|
||||||
|
? Text(
|
||||||
|
channel.description,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(color: context.colors.onSurfaceVariant),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (channel.isMember) ...[
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: Grid.xxs,
|
||||||
|
vertical: Grid.quarter,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.colors.primaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(Grid.half),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Joined',
|
||||||
|
style: context.textTheme.labelSmall?.copyWith(
|
||||||
|
color: context.colors.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: Grid.xxs),
|
||||||
|
],
|
||||||
|
Text(
|
||||||
|
'${channel.memberCount}',
|
||||||
|
style: context.textTheme.bodySmall?.copyWith(
|
||||||
|
color: context.colors.outline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: Grid.quarter),
|
||||||
|
Icon(LucideIcons.users, size: 14, color: context.colors.outline),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _iconFor(Channel channel) {
|
||||||
|
if (channel.isPrivate) return LucideIcons.lock;
|
||||||
|
if (channel.isForum) return LucideIcons.messageSquare;
|
||||||
|
return LucideIcons.hash;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ErrorView extends StatelessWidget {
|
||||||
|
final Object error;
|
||||||
|
final VoidCallback onRetry;
|
||||||
|
|
||||||
|
const _ErrorView({required this.error, required this.onRetry});
|
||||||
|
|
||||||
|
static String _userMessage(Object error) {
|
||||||
|
if (error is RelayException) {
|
||||||
|
if (error.statusCode == 401) {
|
||||||
|
return 'Not authorized. Check your API token.';
|
||||||
|
}
|
||||||
|
if (error.statusCode == 403) {
|
||||||
|
return 'Access denied.';
|
||||||
|
}
|
||||||
|
return 'Server error (${error.statusCode}). Try again later.';
|
||||||
|
}
|
||||||
|
if (error is SocketException) {
|
||||||
|
return 'Could not reach the relay server.';
|
||||||
|
}
|
||||||
|
return 'Something went wrong. Check your connection.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(Grid.sm),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
LucideIcons.wifiOff,
|
||||||
|
size: Grid.xl,
|
||||||
|
color: context.colors.error,
|
||||||
|
),
|
||||||
|
const SizedBox(height: Grid.xs),
|
||||||
|
Text(
|
||||||
|
'Could not load channels',
|
||||||
|
style: context.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: Grid.xxs),
|
||||||
|
Text(
|
||||||
|
_userMessage(error),
|
||||||
|
style: context.textTheme.bodySmall?.copyWith(
|
||||||
|
color: context.colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 3,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: Grid.xs),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: onRetry,
|
||||||
|
icon: const Icon(LucideIcons.refreshCw),
|
||||||
|
label: const Text('Retry'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
mobile/lib/features/channels/channels_provider.dart
Normal file
41
mobile/lib/features/channels/channels_provider.dart
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../shared/relay/relay.dart';
|
||||||
|
import 'channel.dart';
|
||||||
|
|
||||||
|
class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||||
|
@override
|
||||||
|
Future<List<Channel>> build() {
|
||||||
|
// Watch relayClientProvider here so we auto-refetch when config changes.
|
||||||
|
ref.watch(relayClientProvider);
|
||||||
|
return _fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Channel>> _fetch() async {
|
||||||
|
final client = ref.read(relayClientProvider);
|
||||||
|
final json = await client.get('/api/channels') as List<dynamic>;
|
||||||
|
final channels = json
|
||||||
|
.cast<Map<String, dynamic>>()
|
||||||
|
.map(Channel.fromJson)
|
||||||
|
.where((c) => !c.isDm) // exclude DMs from channel list
|
||||||
|
.toList();
|
||||||
|
// Sort: channels with recent activity first, then by name.
|
||||||
|
channels.sort((a, b) {
|
||||||
|
final aTime = a.lastMessageAt;
|
||||||
|
final bTime = b.lastMessageAt;
|
||||||
|
if (aTime != null && bTime != null) return bTime.compareTo(aTime);
|
||||||
|
if (aTime != null) return -1;
|
||||||
|
if (bTime != null) return 1;
|
||||||
|
return a.name.compareTo(b.name);
|
||||||
|
});
|
||||||
|
return channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refresh() async {
|
||||||
|
state = await AsyncValue.guard(_fetch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final channelsProvider = AsyncNotifierProvider<ChannelsNotifier, List<Channel>>(
|
||||||
|
ChannelsNotifier.new,
|
||||||
|
);
|
||||||
@@ -1,39 +1,38 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
|
||||||
import '../../shared/theme/theme.dart';
|
import '../channels/channels_page.dart';
|
||||||
|
import '../settings/settings_page.dart';
|
||||||
|
|
||||||
class HomePage extends HookConsumerWidget {
|
class HomePage extends HookConsumerWidget {
|
||||||
const HomePage({super.key});
|
const HomePage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final tabIndex = useState(0);
|
||||||
|
|
||||||
|
const pages = [ChannelsPage(), SettingsPage()];
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
body: IndexedStack(index: tabIndex.value, children: pages),
|
||||||
title: const Text('Sprout'),
|
bottomNavigationBar: NavigationBar(
|
||||||
actions: [
|
selectedIndex: tabIndex.value,
|
||||||
IconButton(
|
onDestinationSelected: (i) => tabIndex.value = i,
|
||||||
icon: const Icon(LucideIcons.sun),
|
destinations: const [
|
||||||
onPressed: () => ref.read(themeProvider.notifier).toggleTheme(),
|
NavigationDestination(
|
||||||
|
icon: Icon(LucideIcons.hash),
|
||||||
|
selectedIcon: Icon(LucideIcons.hash),
|
||||||
|
label: 'Channels',
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(LucideIcons.settings),
|
||||||
|
selectedIcon: Icon(LucideIcons.settings),
|
||||||
|
label: 'Settings',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Text('Sprout', style: context.textTheme.headlineMedium),
|
|
||||||
const SizedBox(height: Grid.xxs),
|
|
||||||
Text(
|
|
||||||
'Mobile',
|
|
||||||
style: context.textTheme.bodyLarge?.copyWith(
|
|
||||||
color: context.colors.secondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
81
mobile/lib/features/profile/profile_avatar.dart
Normal file
81
mobile/lib/features/profile/profile_avatar.dart
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../shared/theme/theme.dart';
|
||||||
|
import 'profile_provider.dart';
|
||||||
|
import 'user_profile.dart';
|
||||||
|
|
||||||
|
/// User avatar with a presence dot indicator, for use in the app bar.
|
||||||
|
class ProfileAvatar extends ConsumerWidget {
|
||||||
|
const ProfileAvatar({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final profileAsync = ref.watch(profileProvider);
|
||||||
|
final presence =
|
||||||
|
ref.watch(presenceProvider).whenData((v) => v).value ?? 'offline';
|
||||||
|
|
||||||
|
return profileAsync.when(
|
||||||
|
loading: () => const SizedBox(
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
error: (_, _) => _buildAvatar(context, null, presence),
|
||||||
|
data: (profile) => _buildAvatar(context, profile, presence),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAvatar(
|
||||||
|
BuildContext context,
|
||||||
|
UserProfile? profile,
|
||||||
|
String presence,
|
||||||
|
) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: Grid.xxs),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 16,
|
||||||
|
backgroundColor: context.colors.primaryContainer,
|
||||||
|
backgroundImage: profile?.avatarUrl != null
|
||||||
|
? NetworkImage(profile!.avatarUrl!)
|
||||||
|
: null,
|
||||||
|
child: profile?.avatarUrl == null
|
||||||
|
? Text(
|
||||||
|
profile?.initial ?? '?',
|
||||||
|
style: context.textTheme.labelMedium?.copyWith(
|
||||||
|
color: context.colors.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: Container(
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _presenceColor(context, presence),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: context.theme.scaffoldBackgroundColor,
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _presenceColor(BuildContext context, String presence) {
|
||||||
|
return switch (presence) {
|
||||||
|
'online' => context.appColors.success,
|
||||||
|
'away' => context.appColors.warning,
|
||||||
|
_ => context.colors.outline,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
64
mobile/lib/features/profile/profile_provider.dart
Normal file
64
mobile/lib/features/profile/profile_provider.dart
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../shared/relay/relay.dart';
|
||||||
|
import 'user_profile.dart';
|
||||||
|
|
||||||
|
class ProfileNotifier extends AsyncNotifier<UserProfile?> {
|
||||||
|
@override
|
||||||
|
Future<UserProfile?> build() {
|
||||||
|
ref.watch(relayClientProvider);
|
||||||
|
return _fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<UserProfile?> _fetch() async {
|
||||||
|
final client = ref.read(relayClientProvider);
|
||||||
|
try {
|
||||||
|
final json =
|
||||||
|
await client.get('/api/users/me/profile') as Map<String, dynamic>;
|
||||||
|
return UserProfile.fromJson(json);
|
||||||
|
} on RelayException catch (e) {
|
||||||
|
// 404 means user has no profile yet — not an error.
|
||||||
|
if (e.statusCode == 404) return null;
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refresh() async {
|
||||||
|
state = await AsyncValue.guard(_fetch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final profileProvider = AsyncNotifierProvider<ProfileNotifier, UserProfile?>(
|
||||||
|
ProfileNotifier.new,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Presence status for the current user.
|
||||||
|
class PresenceNotifier extends AsyncNotifier<String> {
|
||||||
|
@override
|
||||||
|
Future<String> build() {
|
||||||
|
ref.watch(relayClientProvider);
|
||||||
|
ref.watch(profileProvider);
|
||||||
|
return _fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _fetch() async {
|
||||||
|
final profile = ref.read(profileProvider).whenData((v) => v).value;
|
||||||
|
if (profile == null) return 'offline';
|
||||||
|
final client = ref.read(relayClientProvider);
|
||||||
|
final json =
|
||||||
|
await client.get(
|
||||||
|
'/api/presence',
|
||||||
|
queryParams: {'pubkeys': profile.pubkey},
|
||||||
|
)
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
return (json[profile.pubkey] as String?) ?? 'offline';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refresh() async {
|
||||||
|
state = await AsyncValue.guard(_fetch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final presenceProvider = AsyncNotifierProvider<PresenceNotifier, String>(
|
||||||
|
PresenceNotifier.new,
|
||||||
|
);
|
||||||
33
mobile/lib/features/profile/user_profile.dart
Normal file
33
mobile/lib/features/profile/user_profile.dart
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class UserProfile {
|
||||||
|
final String pubkey;
|
||||||
|
final String? displayName;
|
||||||
|
final String? avatarUrl;
|
||||||
|
final String? about;
|
||||||
|
|
||||||
|
const UserProfile({
|
||||||
|
required this.pubkey,
|
||||||
|
this.displayName,
|
||||||
|
this.avatarUrl,
|
||||||
|
this.about,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory UserProfile.fromJson(Map<String, dynamic> json) => UserProfile(
|
||||||
|
pubkey: json['pubkey'] as String,
|
||||||
|
displayName: json['display_name'] as String?,
|
||||||
|
avatarUrl: json['avatar_url'] as String?,
|
||||||
|
about: json['about'] as String?,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Short label: display name, or first 8 chars of pubkey.
|
||||||
|
String get label =>
|
||||||
|
displayName ??
|
||||||
|
'${pubkey.length >= 8 ? pubkey.substring(0, 8) : pubkey}...';
|
||||||
|
|
||||||
|
/// First letter for fallback avatar.
|
||||||
|
String get initial =>
|
||||||
|
(displayName?.isNotEmpty == true ? displayName! : pubkey)[0]
|
||||||
|
.toUpperCase();
|
||||||
|
}
|
||||||
109
mobile/lib/features/settings/settings_page.dart
Normal file
109
mobile/lib/features/settings/settings_page.dart
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
|
||||||
|
import '../../shared/relay/relay.dart';
|
||||||
|
import '../../shared/theme/theme.dart';
|
||||||
|
|
||||||
|
class SettingsPage extends HookConsumerWidget {
|
||||||
|
const SettingsPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final config = ref.watch(relayConfigProvider);
|
||||||
|
final urlController = useTextEditingController(text: config.baseUrl);
|
||||||
|
final tokenController = useTextEditingController(
|
||||||
|
text: config.apiToken ?? '',
|
||||||
|
);
|
||||||
|
final pubkeyController = useTextEditingController(
|
||||||
|
text: config.devPubkey ?? '',
|
||||||
|
);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Settings')),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.all(Grid.xs),
|
||||||
|
children: [
|
||||||
|
Text('Relay Connection', style: context.textTheme.titleMedium),
|
||||||
|
const SizedBox(height: Grid.twelve),
|
||||||
|
TextField(
|
||||||
|
controller: urlController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Relay URL',
|
||||||
|
hintText: 'http://localhost:3000',
|
||||||
|
prefixIcon: Icon(LucideIcons.server),
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.url,
|
||||||
|
autocorrect: false,
|
||||||
|
),
|
||||||
|
const SizedBox(height: Grid.twelve),
|
||||||
|
TextField(
|
||||||
|
controller: tokenController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'API Token (optional)',
|
||||||
|
hintText: 'sprout_...',
|
||||||
|
prefixIcon: Icon(LucideIcons.key),
|
||||||
|
),
|
||||||
|
obscureText: true,
|
||||||
|
autocorrect: false,
|
||||||
|
),
|
||||||
|
const SizedBox(height: Grid.twelve),
|
||||||
|
TextField(
|
||||||
|
controller: pubkeyController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Dev Pubkey (hex, for local relay)',
|
||||||
|
hintText: '3bf0c63...',
|
||||||
|
prefixIcon: const Icon(LucideIcons.userRound),
|
||||||
|
),
|
||||||
|
autocorrect: false,
|
||||||
|
),
|
||||||
|
const SizedBox(height: Grid.xs),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
final token = tokenController.text.trim();
|
||||||
|
final pubkey = pubkeyController.text.trim();
|
||||||
|
ref
|
||||||
|
.read(relayConfigProvider.notifier)
|
||||||
|
.update(
|
||||||
|
baseUrl: urlController.text.trim(),
|
||||||
|
apiToken: token.isEmpty ? null : token,
|
||||||
|
devPubkey: pubkey.isEmpty ? null : pubkey,
|
||||||
|
);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Relay config updated')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Save'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: Grid.sm),
|
||||||
|
Text('Appearance', style: context.textTheme.titleMedium),
|
||||||
|
const SizedBox(height: Grid.twelve),
|
||||||
|
SegmentedButton<ThemeMode>(
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(
|
||||||
|
value: ThemeMode.light,
|
||||||
|
icon: Icon(LucideIcons.sun),
|
||||||
|
label: Text('Light'),
|
||||||
|
),
|
||||||
|
ButtonSegment(
|
||||||
|
value: ThemeMode.system,
|
||||||
|
icon: Icon(LucideIcons.monitor),
|
||||||
|
label: Text('System'),
|
||||||
|
),
|
||||||
|
ButtonSegment(
|
||||||
|
value: ThemeMode.dark,
|
||||||
|
icon: Icon(LucideIcons.moon),
|
||||||
|
label: Text('Dark'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
selected: {ref.watch(themeProvider)},
|
||||||
|
onSelectionChanged: (modes) {
|
||||||
|
ref.read(themeProvider.notifier).setThemeMode(modes.first);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
2
mobile/lib/shared/relay/relay.dart
Normal file
2
mobile/lib/shared/relay/relay.dart
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export 'relay_client.dart';
|
||||||
|
export 'relay_provider.dart';
|
||||||
77
mobile/lib/shared/relay/relay_client.dart
Normal file
77
mobile/lib/shared/relay/relay_client.dart
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
/// Lightweight HTTP client for talking to the Sprout relay REST API.
|
||||||
|
class RelayClient {
|
||||||
|
final String baseUrl;
|
||||||
|
final String? apiToken;
|
||||||
|
final String? devPubkey;
|
||||||
|
final http.Client _http;
|
||||||
|
|
||||||
|
RelayClient({
|
||||||
|
required this.baseUrl,
|
||||||
|
this.apiToken,
|
||||||
|
this.devPubkey,
|
||||||
|
http.Client? httpClient,
|
||||||
|
}) : _http = httpClient ?? http.Client();
|
||||||
|
|
||||||
|
Map<String, String> get _headers {
|
||||||
|
final h = {'Content-Type': 'application/json'};
|
||||||
|
if (apiToken case final token?) {
|
||||||
|
h['Authorization'] = 'Bearer $token';
|
||||||
|
} else if (devPubkey case final pk?) {
|
||||||
|
h['X-Pubkey'] = pk;
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri _uri(String path, {Map<String, String>? queryParams}) {
|
||||||
|
final base = Uri.parse(baseUrl);
|
||||||
|
// Resolve path against base to avoid double-slash issues.
|
||||||
|
final resolved = base.resolve(path);
|
||||||
|
if (queryParams?.isNotEmpty == true) {
|
||||||
|
return resolved.replace(queryParameters: queryParams);
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET [path] and return decoded JSON.
|
||||||
|
Future<dynamic> get(String path, {Map<String, String>? queryParams}) async {
|
||||||
|
final response = await _http.get(
|
||||||
|
_uri(path, queryParams: queryParams),
|
||||||
|
headers: _headers,
|
||||||
|
);
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
throw RelayException(response.statusCode, response.body);
|
||||||
|
}
|
||||||
|
return jsonDecode(response.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST [path] with a JSON [body] and return decoded JSON, or null for
|
||||||
|
/// empty responses (e.g. 204).
|
||||||
|
Future<dynamic> post(String path, {Object? body}) async {
|
||||||
|
final response = await _http.post(
|
||||||
|
_uri(path),
|
||||||
|
headers: _headers,
|
||||||
|
body: body != null ? jsonEncode(body) : null,
|
||||||
|
);
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
throw RelayException(response.statusCode, response.body);
|
||||||
|
}
|
||||||
|
if (response.body.isEmpty) return null;
|
||||||
|
return jsonDecode(response.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() => _http.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
class RelayException implements Exception {
|
||||||
|
final int statusCode;
|
||||||
|
final String body;
|
||||||
|
|
||||||
|
RelayException(this.statusCode, this.body);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'RelayException($statusCode): $body';
|
||||||
|
}
|
||||||
70
mobile/lib/shared/relay/relay_provider.dart
Normal file
70
mobile/lib/shared/relay/relay_provider.dart
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
import 'relay_client.dart';
|
||||||
|
|
||||||
|
/// Relay connection configuration.
|
||||||
|
class RelayConfig {
|
||||||
|
final String baseUrl;
|
||||||
|
final String? apiToken;
|
||||||
|
|
||||||
|
/// Hex pubkey for dev-mode auth via X-Pubkey header.
|
||||||
|
/// Used when the relay has `SPROUT_REQUIRE_AUTH_TOKEN=false`.
|
||||||
|
final String? devPubkey;
|
||||||
|
|
||||||
|
const RelayConfig({required this.baseUrl, this.apiToken, this.devPubkey});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compile-time environment config via --dart-define.
|
||||||
|
///
|
||||||
|
/// Run with:
|
||||||
|
/// flutter run \
|
||||||
|
/// --dart-define=SPROUT_RELAY_URL=http://localhost:3000 \
|
||||||
|
/// --dart-define=SPROUT_DEV_PUBKEY=5e58f620... \
|
||||||
|
/// --dart-define=SPROUT_API_TOKEN=sprout_...
|
||||||
|
///
|
||||||
|
/// Or create a `.env.json` and use --dart-define-from-file=.env.json
|
||||||
|
class Env {
|
||||||
|
static const relayUrl = String.fromEnvironment(
|
||||||
|
'SPROUT_RELAY_URL',
|
||||||
|
defaultValue: 'http://localhost:3000',
|
||||||
|
);
|
||||||
|
static const devPubkey = String.fromEnvironment('SPROUT_DEV_PUBKEY');
|
||||||
|
static const apiToken = String.fromEnvironment('SPROUT_API_TOKEN');
|
||||||
|
}
|
||||||
|
|
||||||
|
class RelayConfigNotifier extends Notifier<RelayConfig> {
|
||||||
|
@override
|
||||||
|
RelayConfig build() => RelayConfig(
|
||||||
|
baseUrl: Env.relayUrl,
|
||||||
|
apiToken: Env.apiToken.isEmpty ? null : Env.apiToken,
|
||||||
|
devPubkey: Env.devPubkey.isEmpty ? null : Env.devPubkey,
|
||||||
|
);
|
||||||
|
|
||||||
|
void update({
|
||||||
|
required String baseUrl,
|
||||||
|
required String? apiToken,
|
||||||
|
required String? devPubkey,
|
||||||
|
}) {
|
||||||
|
state = RelayConfig(
|
||||||
|
baseUrl: baseUrl,
|
||||||
|
apiToken: apiToken,
|
||||||
|
devPubkey: devPubkey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final relayConfigProvider = NotifierProvider<RelayConfigNotifier, RelayConfig>(
|
||||||
|
RelayConfigNotifier.new,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Provides a [RelayClient] that reacts to config changes.
|
||||||
|
final relayClientProvider = Provider<RelayClient>((ref) {
|
||||||
|
final config = ref.watch(relayConfigProvider);
|
||||||
|
final client = RelayClient(
|
||||||
|
baseUrl: config.baseUrl,
|
||||||
|
apiToken: config.apiToken,
|
||||||
|
devPubkey: config.devPubkey,
|
||||||
|
);
|
||||||
|
ref.onDispose(client.dispose);
|
||||||
|
return client;
|
||||||
|
});
|
||||||
@@ -275,6 +275,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.0"
|
version: "3.1.0"
|
||||||
|
http:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: http
|
||||||
|
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.6.0"
|
||||||
http_multi_server:
|
http_multi_server:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ dependencies:
|
|||||||
hooks_riverpod: ^3.0.3
|
hooks_riverpod: ^3.0.3
|
||||||
flutter_hooks: ^0.21.3
|
flutter_hooks: ^0.21.3
|
||||||
lucide_icons_flutter: ^3.1.0
|
lucide_icons_flutter: ^3.1.0
|
||||||
|
http: ^1.4.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
81
mobile/test/features/channels/channel_test.dart
Normal file
81
mobile/test/features/channels/channel_test.dart
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:sprout_mobile/features/channels/channel.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('Channel.fromJson', () {
|
||||||
|
test('parses a full channel response', () {
|
||||||
|
final json = {
|
||||||
|
'id': 'abc-123',
|
||||||
|
'name': 'general',
|
||||||
|
'channel_type': 'stream',
|
||||||
|
'visibility': 'open',
|
||||||
|
'description': 'General discussion',
|
||||||
|
'topic': 'Welcome!',
|
||||||
|
'purpose': 'Team chat',
|
||||||
|
'created_by': 'deadbeef',
|
||||||
|
'created_at': '2025-01-01T00:00:00+00:00',
|
||||||
|
'member_count': 42,
|
||||||
|
'last_message_at': '2025-06-01T12:00:00+00:00',
|
||||||
|
'is_member': true,
|
||||||
|
};
|
||||||
|
|
||||||
|
final channel = Channel.fromJson(json);
|
||||||
|
|
||||||
|
expect(channel.id, 'abc-123');
|
||||||
|
expect(channel.name, 'general');
|
||||||
|
expect(channel.channelType, 'stream');
|
||||||
|
expect(channel.visibility, 'open');
|
||||||
|
expect(channel.description, 'General discussion');
|
||||||
|
expect(channel.topic, 'Welcome!');
|
||||||
|
expect(channel.purpose, 'Team chat');
|
||||||
|
expect(channel.memberCount, 42);
|
||||||
|
expect(channel.isMember, isTrue);
|
||||||
|
expect(channel.isStream, isTrue);
|
||||||
|
expect(channel.isForum, isFalse);
|
||||||
|
expect(channel.isDm, isFalse);
|
||||||
|
expect(channel.isPrivate, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles null optional fields', () {
|
||||||
|
final json = {
|
||||||
|
'id': 'abc-123',
|
||||||
|
'name': 'private-chat',
|
||||||
|
'channel_type': 'stream',
|
||||||
|
'visibility': 'private',
|
||||||
|
'description': null,
|
||||||
|
'topic': null,
|
||||||
|
'purpose': null,
|
||||||
|
'created_by': 'deadbeef',
|
||||||
|
'created_at': '2025-01-01T00:00:00+00:00',
|
||||||
|
'member_count': 2,
|
||||||
|
'last_message_at': null,
|
||||||
|
'is_member': false,
|
||||||
|
};
|
||||||
|
|
||||||
|
final channel = Channel.fromJson(json);
|
||||||
|
|
||||||
|
expect(channel.description, '');
|
||||||
|
expect(channel.topic, isNull);
|
||||||
|
expect(channel.lastMessageAt, isNull);
|
||||||
|
expect(channel.isMember, isFalse);
|
||||||
|
expect(channel.isPrivate, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('defaults is_member to false when missing', () {
|
||||||
|
final json = {
|
||||||
|
'id': 'abc-123',
|
||||||
|
'name': 'test',
|
||||||
|
'channel_type': 'forum',
|
||||||
|
'visibility': 'open',
|
||||||
|
'created_by': 'deadbeef',
|
||||||
|
'created_at': '2025-01-01T00:00:00+00:00',
|
||||||
|
'member_count': 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
final channel = Channel.fromJson(json);
|
||||||
|
|
||||||
|
expect(channel.isMember, isFalse);
|
||||||
|
expect(channel.isForum, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
116
mobile/test/features/channels/channels_page_test.dart
Normal file
116
mobile/test/features/channels/channels_page_test.dart
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:hooks_riverpod/misc.dart';
|
||||||
|
import 'package:sprout_mobile/features/channels/channel.dart';
|
||||||
|
import 'package:sprout_mobile/features/channels/channels_page.dart';
|
||||||
|
import 'package:sprout_mobile/features/channels/channels_provider.dart';
|
||||||
|
import 'package:sprout_mobile/features/profile/profile_provider.dart';
|
||||||
|
import 'package:sprout_mobile/features/profile/user_profile.dart';
|
||||||
|
import 'package:sprout_mobile/shared/theme/theme.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
Widget buildTestable({required List<Override> overrides}) {
|
||||||
|
return ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
// Provide a fake profile and presence so the avatar doesn't hit the network.
|
||||||
|
profileProvider.overrideWith(() => _FakeProfileNotifier()),
|
||||||
|
presenceProvider.overrideWith(() => _FakePresenceNotifier()),
|
||||||
|
...overrides,
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
theme: AppTheme.lightTheme,
|
||||||
|
home: const ChannelsPage(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final testChannels = [
|
||||||
|
Channel(
|
||||||
|
id: '1',
|
||||||
|
name: 'general',
|
||||||
|
channelType: 'stream',
|
||||||
|
visibility: 'open',
|
||||||
|
description: 'General discussion',
|
||||||
|
createdBy: 'abc',
|
||||||
|
createdAt: DateTime(2025),
|
||||||
|
memberCount: 10,
|
||||||
|
isMember: true,
|
||||||
|
),
|
||||||
|
Channel(
|
||||||
|
id: '2',
|
||||||
|
name: 'secret',
|
||||||
|
channelType: 'stream',
|
||||||
|
visibility: 'private',
|
||||||
|
description: 'Private channel',
|
||||||
|
createdBy: 'abc',
|
||||||
|
createdAt: DateTime(2025),
|
||||||
|
memberCount: 3,
|
||||||
|
isMember: false,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
testWidgets('shows channel list when data loads', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildTestable(
|
||||||
|
overrides: [
|
||||||
|
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('general'), findsOneWidget);
|
||||||
|
expect(find.text('secret'), findsOneWidget);
|
||||||
|
expect(find.text('Joined'), findsOneWidget);
|
||||||
|
expect(find.text('10'), findsOneWidget);
|
||||||
|
expect(find.text('3'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows empty state when no channels', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildTestable(
|
||||||
|
overrides: [channelsProvider.overrideWith(() => _FakeNotifier([]))],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('No channels yet'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows error view with retry button', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildTestable(
|
||||||
|
overrides: [channelsProvider.overrideWith(() => _ErrorNotifier())],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Could not load channels'), findsOneWidget);
|
||||||
|
expect(find.text('Retry'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeNotifier extends ChannelsNotifier {
|
||||||
|
final List<Channel> _channels;
|
||||||
|
_FakeNotifier(this._channels);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Channel>> build() async => _channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ErrorNotifier extends ChannelsNotifier {
|
||||||
|
@override
|
||||||
|
Future<List<Channel>> build() => Future.error('Connection refused');
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeProfileNotifier extends ProfileNotifier {
|
||||||
|
@override
|
||||||
|
Future<UserProfile?> build() async =>
|
||||||
|
const UserProfile(pubkey: 'aabb', displayName: 'Test');
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakePresenceNotifier extends PresenceNotifier {
|
||||||
|
@override
|
||||||
|
Future<String> build() async => 'online';
|
||||||
|
}
|
||||||
80
mobile/test/shared/relay/relay_client_test.dart
Normal file
80
mobile/test/shared/relay/relay_client_test.dart
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart' as http_testing;
|
||||||
|
import 'package:sprout_mobile/shared/relay/relay_client.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('RelayClient', () {
|
||||||
|
test('GET sends auth header and parses JSON', () async {
|
||||||
|
final mockClient = http_testing.MockClient((request) async {
|
||||||
|
expect(request.url.toString(), 'http://test:3000/api/channels');
|
||||||
|
expect(request.headers['Authorization'], 'Bearer sprout_abc');
|
||||||
|
expect(request.headers['Content-Type'], 'application/json');
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode([
|
||||||
|
{'id': '1', 'name': 'general'},
|
||||||
|
]),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final client = RelayClient(
|
||||||
|
baseUrl: 'http://test:3000',
|
||||||
|
apiToken: 'sprout_abc',
|
||||||
|
httpClient: mockClient,
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await client.get('/api/channels');
|
||||||
|
expect(result, isList);
|
||||||
|
expect((result as List).first['name'], 'general');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET with query parameters', () async {
|
||||||
|
final mockClient = http_testing.MockClient((request) async {
|
||||||
|
expect(request.url.queryParameters['visibility'], 'open');
|
||||||
|
return http.Response(jsonEncode([]), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
final client = RelayClient(
|
||||||
|
baseUrl: 'http://test:3000',
|
||||||
|
httpClient: mockClient,
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.get('/api/channels', queryParams: {'visibility': 'open'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws RelayException on non-200', () async {
|
||||||
|
final mockClient = http_testing.MockClient((request) async {
|
||||||
|
return http.Response('{"error": "unauthorized"}', 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
final client = RelayClient(
|
||||||
|
baseUrl: 'http://test:3000',
|
||||||
|
httpClient: mockClient,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => client.get('/api/channels'),
|
||||||
|
throwsA(
|
||||||
|
isA<RelayException>().having((e) => e.statusCode, 'statusCode', 401),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('omits Authorization header when no token', () async {
|
||||||
|
final mockClient = http_testing.MockClient((request) async {
|
||||||
|
expect(request.headers.containsKey('Authorization'), isFalse);
|
||||||
|
return http.Response(jsonEncode({}), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
final client = RelayClient(
|
||||||
|
baseUrl: 'http://test:3000',
|
||||||
|
httpClient: mockClient,
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.get('/api/test');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,6 +5,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
void main() {
|
void main() {
|
||||||
testWidgets('App renders without crashing', (WidgetTester tester) async {
|
testWidgets('App renders without crashing', (WidgetTester tester) async {
|
||||||
await tester.pumpWidget(const ProviderScope(child: App()));
|
await tester.pumpWidget(const ProviderScope(child: App()));
|
||||||
expect(find.text('Sprout'), findsWidgets);
|
expect(find.text('Channels'), findsWidgets);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user