Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions mobile/.env.json.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"SPROUT_RELAY_URL": "http://localhost:3000",
"SPROUT_DEV_PUBKEY": "<your-hex-pubkey-here>"
}
3 changes: 3 additions & 0 deletions mobile/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,6 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release

# Local environment config (contains keys)
.env.json
54 changes: 54 additions & 0 deletions mobile/lib/features/channels/channel.dart
Original file line number Diff line number Diff line change
@@ -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 changes: 222 additions & 0 deletions mobile/lib/features/channels/channels_page.dart
Original file line number Diff line number Diff line change
@@ -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 changes: 41 additions & 0 deletions mobile/lib/features/channels/channels_provider.dart
Original file line number Diff line number Diff line change
@@ -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,
);
43 changes: 21 additions & 22 deletions mobile/lib/features/home/home_page.dart
Original file line number Diff line number Diff line change
@@ -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,
),
),
],
),
),
);
}
}
Loading