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:
Wes
2026-04-14 08:42:29 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent be83699559
commit 08be36471e
19 changed files with 1068 additions and 23 deletions
+4
View File
@@ -0,0 +1,4 @@
{
"SPROUT_RELAY_URL": "http://localhost:3000",
"SPROUT_DEV_PUBKEY": "<your-hex-pubkey-here>"
}
+3
View File
@@ -43,3 +43,6 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
# Local environment config (contains keys)
.env.json
+54
View 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';
}
@@ -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'),
),
],
),
),
);
}
}
@@ -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,
);
+21 -22
View File
@@ -1,39 +1,38 @@
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/theme/theme.dart';
import '../channels/channels_page.dart';
import '../settings/settings_page.dart';
class HomePage extends HookConsumerWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabIndex = useState(0);
const pages = [ChannelsPage(), SettingsPage()];
return Scaffold(
appBar: AppBar(
title: const Text('Sprout'),
actions: [
IconButton(
icon: const Icon(LucideIcons.sun),
onPressed: () => ref.read(themeProvider.notifier).toggleTheme(),
body: IndexedStack(index: tabIndex.value, children: pages),
bottomNavigationBar: NavigationBar(
selectedIndex: tabIndex.value,
onDestinationSelected: (i) => tabIndex.value = i,
destinations: const [
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,
),
),
],
),
),
);
}
}
@@ -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,
};
}
}
@@ -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,
);
@@ -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();
}
@@ -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
View File
@@ -0,0 +1,2 @@
export 'relay_client.dart';
export 'relay_provider.dart';
+77
View 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';
}
@@ -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;
});
+8
View File
@@ -275,6 +275,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
+1
View File
@@ -12,6 +12,7 @@ dependencies:
hooks_riverpod: ^3.0.3
flutter_hooks: ^0.21.3
lucide_icons_flutter: ^3.1.0
http: ^1.4.0
dev_dependencies:
flutter_test:
@@ -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);
});
});
}
@@ -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';
}
@@ -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');
});
});
}
+1 -1
View File
@@ -5,6 +5,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
void main() {
testWidgets('App renders without crashing', (WidgetTester tester) async {
await tester.pumpWidget(const ProviderScope(child: App()));
expect(find.text('Sprout'), findsWidgets);
expect(find.text('Channels'), findsWidgets);
});
}