diff --git a/mobile/.env.json.example b/mobile/.env.json.example new file mode 100644 index 00000000000..cfbbe0776a1 --- /dev/null +++ b/mobile/.env.json.example @@ -0,0 +1,4 @@ +{ + "SPROUT_RELAY_URL": "http://localhost:3000", + "SPROUT_DEV_PUBKEY": "" +} diff --git a/mobile/.gitignore b/mobile/.gitignore index 3820a95c65c..4782a3d71fd 100644 --- a/mobile/.gitignore +++ b/mobile/.gitignore @@ -43,3 +43,6 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# Local environment config (contains keys) +.env.json diff --git a/mobile/lib/features/channels/channel.dart b/mobile/lib/features/channels/channel.dart new file mode 100644 index 00000000000..dea3a0f49e2 --- /dev/null +++ b/mobile/lib/features/channels/channel.dart @@ -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 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'; +} diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart new file mode 100644 index 00000000000..c8541c999b2 --- /dev/null +++ b/mobile/lib/features/channels/channels_page.dart @@ -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 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'), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart new file mode 100644 index 00000000000..62c0d953dae --- /dev/null +++ b/mobile/lib/features/channels/channels_provider.dart @@ -0,0 +1,41 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/relay/relay.dart'; +import 'channel.dart'; + +class ChannelsNotifier extends AsyncNotifier> { + @override + Future> build() { + // Watch relayClientProvider here so we auto-refetch when config changes. + ref.watch(relayClientProvider); + return _fetch(); + } + + Future> _fetch() async { + final client = ref.read(relayClientProvider); + final json = await client.get('/api/channels') as List; + final channels = json + .cast>() + .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 refresh() async { + state = await AsyncValue.guard(_fetch); + } +} + +final channelsProvider = AsyncNotifierProvider>( + ChannelsNotifier.new, +); diff --git a/mobile/lib/features/home/home_page.dart b/mobile/lib/features/home/home_page.dart index f1c40a84d97..63b811d72b5 100644 --- a/mobile/lib/features/home/home_page.dart +++ b/mobile/lib/features/home/home_page.dart @@ -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, - ), - ), - ], - ), - ), ); } } diff --git a/mobile/lib/features/profile/profile_avatar.dart b/mobile/lib/features/profile/profile_avatar.dart new file mode 100644 index 00000000000..d18d4c5b918 --- /dev/null +++ b/mobile/lib/features/profile/profile_avatar.dart @@ -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, + }; + } +} diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart new file mode 100644 index 00000000000..a5c7ffc4feb --- /dev/null +++ b/mobile/lib/features/profile/profile_provider.dart @@ -0,0 +1,64 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/relay/relay.dart'; +import 'user_profile.dart'; + +class ProfileNotifier extends AsyncNotifier { + @override + Future build() { + ref.watch(relayClientProvider); + return _fetch(); + } + + Future _fetch() async { + final client = ref.read(relayClientProvider); + try { + final json = + await client.get('/api/users/me/profile') as Map; + 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 refresh() async { + state = await AsyncValue.guard(_fetch); + } +} + +final profileProvider = AsyncNotifierProvider( + ProfileNotifier.new, +); + +/// Presence status for the current user. +class PresenceNotifier extends AsyncNotifier { + @override + Future build() { + ref.watch(relayClientProvider); + ref.watch(profileProvider); + return _fetch(); + } + + Future _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; + return (json[profile.pubkey] as String?) ?? 'offline'; + } + + Future refresh() async { + state = await AsyncValue.guard(_fetch); + } +} + +final presenceProvider = AsyncNotifierProvider( + PresenceNotifier.new, +); diff --git a/mobile/lib/features/profile/user_profile.dart b/mobile/lib/features/profile/user_profile.dart new file mode 100644 index 00000000000..421a396db79 --- /dev/null +++ b/mobile/lib/features/profile/user_profile.dart @@ -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 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(); +} diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart new file mode 100644 index 00000000000..289fa6039ad --- /dev/null +++ b/mobile/lib/features/settings/settings_page.dart @@ -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( + 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); + }, + ), + ], + ), + ); + } +} diff --git a/mobile/lib/shared/relay/relay.dart b/mobile/lib/shared/relay/relay.dart new file mode 100644 index 00000000000..316474c29ad --- /dev/null +++ b/mobile/lib/shared/relay/relay.dart @@ -0,0 +1,2 @@ +export 'relay_client.dart'; +export 'relay_provider.dart'; diff --git a/mobile/lib/shared/relay/relay_client.dart b/mobile/lib/shared/relay/relay_client.dart new file mode 100644 index 00000000000..363ccc631da --- /dev/null +++ b/mobile/lib/shared/relay/relay_client.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 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? 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 get(String path, {Map? 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 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'; +} diff --git a/mobile/lib/shared/relay/relay_provider.dart b/mobile/lib/shared/relay/relay_provider.dart new file mode 100644 index 00000000000..7676ae3d709 --- /dev/null +++ b/mobile/lib/shared/relay/relay_provider.dart @@ -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 { + @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.new, +); + +/// Provides a [RelayClient] that reacts to config changes. +final relayClientProvider = Provider((ref) { + final config = ref.watch(relayConfigProvider); + final client = RelayClient( + baseUrl: config.baseUrl, + apiToken: config.apiToken, + devPubkey: config.devPubkey, + ); + ref.onDispose(client.dispose); + return client; +}); diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 552ee4e3b52..d09e4981f0f 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -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: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 5fb986f6d0e..0c3c7f30ae6 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -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: diff --git a/mobile/test/features/channels/channel_test.dart b/mobile/test/features/channels/channel_test.dart new file mode 100644 index 00000000000..f8f972cd996 --- /dev/null +++ b/mobile/test/features/channels/channel_test.dart @@ -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); + }); + }); +} diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart new file mode 100644 index 00000000000..05972861761 --- /dev/null +++ b/mobile/test/features/channels/channels_page_test.dart @@ -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 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 _channels; + _FakeNotifier(this._channels); + + @override + Future> build() async => _channels; +} + +class _ErrorNotifier extends ChannelsNotifier { + @override + Future> build() => Future.error('Connection refused'); +} + +class _FakeProfileNotifier extends ProfileNotifier { + @override + Future build() async => + const UserProfile(pubkey: 'aabb', displayName: 'Test'); +} + +class _FakePresenceNotifier extends PresenceNotifier { + @override + Future build() async => 'online'; +} diff --git a/mobile/test/shared/relay/relay_client_test.dart b/mobile/test/shared/relay/relay_client_test.dart new file mode 100644 index 00000000000..cde0d2cd33d --- /dev/null +++ b/mobile/test/shared/relay/relay_client_test.dart @@ -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().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'); + }); + }); +} diff --git a/mobile/test/widget_test.dart b/mobile/test/widget_test.dart index c45810184b1..412969ff258 100644 --- a/mobile/test/widget_test.dart +++ b/mobile/test/widget_test.dart @@ -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); }); }