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
Binary file added mobile/assets/fonts/Geist-Italic-Variable.ttf
Binary file not shown.
Binary file added mobile/assets/fonts/Geist-Variable.ttf
Binary file not shown.
Binary file not shown.
Binary file added mobile/assets/fonts/GeistMono-Variable.ttf
Binary file not shown.
7 changes: 7 additions & 0 deletions mobile/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,15 @@ PODS:
- nanopb/decode (3.30910.0)
- nanopb/encode (3.30910.0)
- PromisesObjC (2.4.0)
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS

DEPENDENCIES:
- Flutter (from `Flutter`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)

SPEC REPOS:
trunk:
Expand All @@ -78,6 +82,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_secure_storage/ios"
mobile_scanner:
:path: ".symlinks/plugins/mobile_scanner/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"

SPEC CHECKSUMS:
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
Expand All @@ -94,6 +100,7 @@ SPEC CHECKSUMS:
mobile_scanner: af8f71879eaba2bbcb4d86c6a462c3c0e7f23036
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb

PODFILE CHECKSUM: bd29822c3d5baf6b44b726f00ea3293a19339ef2

Expand Down
8 changes: 6 additions & 2 deletions mobile/lib/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,16 @@ class App extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final themeMode = ref.watch(themeProvider);
final accentIndex = ref.watch(accentProvider);
final authState = ref.watch(authProvider);

final lightScheme = applyAccent(lightColorScheme, accentIndex);
final darkScheme = applyAccent(darkColorScheme, accentIndex);

return MaterialApp(
title: 'Sprout',
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
theme: AppTheme.light(colorScheme: lightScheme),
darkTheme: AppTheme.dark(colorScheme: darkScheme),
themeMode: themeMode,
home: authState.when(
loading: () => const _SplashScreen(),
Expand Down
10 changes: 10 additions & 0 deletions mobile/lib/features/channels/channel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ class Channel {
final int memberCount;
final DateTime? lastMessageAt;
final bool isMember;
final int? ttlSeconds;
final DateTime? ttlDeadline;

const Channel({
required this.id,
Expand All @@ -28,6 +30,8 @@ class Channel {
this.purpose,
this.lastMessageAt,
this.isMember = false,
this.ttlSeconds,
this.ttlDeadline,
});

factory Channel.fromJson(Map<String, dynamic> json) => Channel(
Expand All @@ -45,8 +49,14 @@ class Channel {
? DateTime.parse(json['last_message_at'] as String)
: null,
isMember: json['is_member'] as bool? ?? false,
ttlSeconds: json['ttl_seconds'] as int?,
ttlDeadline: json['ttl_deadline'] != null
? DateTime.parse(json['ttl_deadline'] as String)
: null,
);

bool get isEphemeral => ttlSeconds != null;

bool get isStream => channelType == 'stream';
bool get isForum => channelType == 'forum';
bool get isDm => channelType == 'dm';
Expand Down
221 changes: 171 additions & 50 deletions mobile/lib/features/channels/channels_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ class ChannelsPage extends HookConsumerWidget {

return Scaffold(
appBar: AppBar(
title: const Text('Channels'),
title: const Text('Sprout'),
actions: const [ProfileAvatar()],
),
body: channelsAsync.when(
Expand Down Expand Up @@ -81,81 +81,202 @@ class _ChannelsList extends ConsumerWidget {
),
],
)
: 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]),
: ListView.builder(
padding: const EdgeInsets.only(top: Grid.xxs),
itemCount: channels.length + 1, // +1 for section header
itemBuilder: (context, index) {
if (index == 0) {
return _SectionHeader(
label: 'Channels',
count: channels.length,
);
}
return _ChannelTile(channel: channels[index - 1]);
},
),
);
}
}

class _ChannelTile extends StatelessWidget {
final Channel channel;
/// Slack-style section header: "Channels 12"
class _SectionHeader extends StatelessWidget {
final String label;
final int count;

const _ChannelTile({required this.channel});
const _SectionHeader({required this.label, required this.count});

@override
Widget build(BuildContext context) {
return ListTile(
leading: Icon(
_iconFor(channel),
color: channel.isMember
? context.colors.primary
: context.colors.outline,
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xs,
vertical: Grid.xxs,
),
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,
child: Row(
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,
),
),
Text(
label,
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
const SizedBox(width: Grid.xxs),
],
),
const SizedBox(width: Grid.xxs),
Text(
'${channel.memberCount}',
style: context.textTheme.bodySmall?.copyWith(
'$count',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.outline,
),
),
const SizedBox(width: Grid.quarter),
Icon(LucideIcons.users, size: 14, color: context.colors.outline),
],
),
);
}
}

/// Compact Slack-style channel row:
/// # channel-name 3m
class _ChannelTile extends StatelessWidget {
final Channel channel;

const _ChannelTile({required this.channel});

@override
Widget build(BuildContext context) {
final hasActivity = channel.lastMessageAt != null;

return InkWell(
borderRadius: BorderRadius.circular(Radii.md),
onTap: () {
// TODO: navigate to channel
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xs,
vertical: Grid.xxs + Grid.quarter,
),
child: Row(
children: [
// Channel type icon
Icon(
_iconFor(channel),
size: 18,
color: hasActivity
? context.colors.onSurface
: context.colors.outline,
),
const SizedBox(width: Grid.xxs),

// Channel name
Expanded(
child: Text(
channel.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyMedium?.copyWith(
color: hasActivity
? context.colors.onSurface
: context.colors.onSurfaceVariant,
),
),
),

// Ephemeral badge
if (channel.isEphemeral) ...[
const SizedBox(width: Grid.xxs),
_EphemeralBadge(channel: channel),
],

// Relative timestamp
if (channel.lastMessageAt != null) ...[
const SizedBox(width: Grid.xxs),
Text(
_relativeTime(channel.lastMessageAt!),
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.outline,
),
),
],
],
),
),
);
}

IconData _iconFor(Channel channel) {
if (channel.isPrivate) return LucideIcons.lock;
if (channel.isForum) return LucideIcons.messageSquare;
return LucideIcons.hash;
}

static String _relativeTime(DateTime dt) {
final diff = DateTime.now().difference(dt);
if (diff.inMinutes < 1) return 'now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m';
if (diff.inHours < 24) return '${diff.inHours}h';
if (diff.inDays < 7) return '${diff.inDays}d';
if (diff.inDays < 365) return '${(diff.inDays / 7).floor()}w';
return '${(diff.inDays / 365).floor()}y';
}
}

/// Small amber clock badge matching the desktop's EphemeralChannelBadge.
class _EphemeralBadge extends StatelessWidget {
final Channel channel;

const _EphemeralBadge({required this.channel});

@override
Widget build(BuildContext context) {
final label = _label();

return Tooltip(
message: 'Ephemeral channel — cleans up after inactivity',
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFFF59E0B).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(Radii.sm),
border: Border.all(
color: const Color(0xFFF59E0B).withValues(alpha: 0.2),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.clock, size: 10, color: _amberColor(context)),
if (label != null) ...[
const SizedBox(width: 3),
Text(
label,
style: context.textTheme.labelSmall?.copyWith(
fontSize: 10,
color: _amberColor(context),
fontWeight: FontWeight.w500,
),
),
],
],
),
),
);
}

Color _amberColor(BuildContext context) {
return context.colors.brightness == Brightness.light
? const Color(0xFFB45309) // amber-700
: const Color(0xFFFCD34D); // amber-300
}

String? _label() {
final deadline = channel.ttlDeadline;
if (deadline == null) return null;
final diff = deadline.difference(DateTime.now());
if (diff.isNegative) return 'due';
if (diff.inMinutes < 60) return '${diff.inMinutes}m';
if (diff.inHours < 24) return '${diff.inHours}h';
return '${diff.inDays}d';
}
}

class _ErrorView extends StatelessWidget {
Expand Down
10 changes: 1 addition & 9 deletions mobile/lib/features/channels/channels_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
.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);
});
channels.sort((a, b) => a.name.compareTo(b.name));
return channels;
}

Expand Down
Loading