mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02: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:
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"SPROUT_RELAY_URL": "http://localhost:3000",
|
||||
"SPROUT_DEV_PUBKEY": "<your-hex-pubkey-here>"
|
||||
}
|
||||
@@ -43,3 +43,6 @@ app.*.map.json
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
|
||||
# Local environment config (contains keys)
|
||||
.env.json
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export 'relay_client.dart';
|
||||
export 'relay_provider.dart';
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user