feat(mobile): align design language with desktop and polish UI (#321)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-14 14:33:48 -07:00
committed by GitHub
parent 2f4e394866
commit 3eabe74c61
21 changed files with 707 additions and 229 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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:
@@ -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
@@ -94,6 +100,7 @@ SPEC CHECKSUMS:
mobile_scanner: af8f71879eaba2bbcb4d86c6a462c3c0e7f23036
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
PODFILE CHECKSUM: bd29822c3d5baf6b44b726f00ea3293a19339ef2

View File

@@ -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(),

View File

@@ -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,
@@ -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(
@@ -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';

View File

@@ -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(
@@ -81,18 +81,61 @@ 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]);
},
),
);
}
}
/// Slack-style section header: "Channels 12"
class _SectionHeader extends StatelessWidget {
final String label;
final int count;
const _SectionHeader({required this.label, required this.count});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xs,
vertical: Grid.xxs,
),
child: Row(
children: [
Text(
label,
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: Grid.xxs),
Text(
'$count',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.outline,
),
),
],
),
);
}
}
/// Compact Slack-style channel row:
/// # channel-name 3m
class _ChannelTile extends StatelessWidget {
final Channel channel;
@@ -100,53 +143,62 @@ class _ChannelTile extends StatelessWidget {
@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),
),
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(
'Joined',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onPrimaryContainer,
channel.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyMedium?.copyWith(
color: hasActivity
? context.colors.onSurface
: context.colors.onSurfaceVariant,
),
),
),
const SizedBox(width: Grid.xxs),
// 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,
),
),
],
],
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),
],
),
),
);
}
@@ -156,6 +208,75 @@ class _ChannelTile extends StatelessWidget {
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 {

View File

@@ -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;
}

View File

@@ -12,6 +12,7 @@ class SettingsPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final config = ref.watch(relayConfigProvider);
final selectedAccent = ref.watch(accentProvider);
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
@@ -31,7 +32,7 @@ class SettingsPage extends HookConsumerWidget {
),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(Radii.md),
side: BorderSide(color: context.colors.outlineVariant),
),
),
@@ -73,6 +74,39 @@ class SettingsPage extends HookConsumerWidget {
ref.read(themeProvider.notifier).setThemeMode(modes.first);
},
),
const SizedBox(height: Grid.xs),
// Accent color picker
Text('Accent Color', style: context.textTheme.titleSmall),
const SizedBox(height: Grid.xxs),
Wrap(
spacing: Grid.xxs,
runSpacing: Grid.xxs,
children: [
// Default (Mauve) swatch
_AccentSwatch(
color: context.colors.brightness == Brightness.light
? const Color(0xFF8839EF)
: const Color(0xFFC6A0F6),
label: 'Mauve',
selected: selectedAccent == defaultAccentIndex,
onTap: () => ref
.read(accentProvider.notifier)
.setAccent(defaultAccentIndex),
),
// 8 accent colors from desktop
for (var i = 0; i < accentColors.length; i++)
_AccentSwatch(
color: context.colors.brightness == Brightness.light
? accentColors[i].light
: accentColors[i].dark,
label: accentColors[i].name,
selected: selectedAccent == i,
onTap: () => ref.read(accentProvider.notifier).setAccent(i),
),
],
),
],
),
);
@@ -107,3 +141,51 @@ class SettingsPage extends HookConsumerWidget {
);
}
}
class _AccentSwatch extends StatelessWidget {
const _AccentSwatch({
required this.color,
required this.label,
required this.selected,
required this.onTap,
});
final Color color;
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Tooltip(
message: label,
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: 36,
height: 36,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(Radii.md),
border: selected
? Border.all(color: context.colors.onSurface, width: 2.5)
: Border.all(color: color.withValues(alpha: 0.4), width: 1),
),
child: selected
? Icon(LucideIcons.check, size: 16, color: _contrastColor(color))
: null,
),
),
);
}
static Color _contrastColor(Color bg) {
final lum = bg.computeLuminance();
final contrastWithBlack = (lum + 0.05) / 0.05;
final contrastWithWhite = 1.05 / (lum + 0.05);
return contrastWithBlack >= contrastWithWhite
? const Color(0xFF000000)
: const Color(0xFFFFFFFF);
}
}

View File

@@ -1,10 +1,20 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'app.dart';
import 'shared/theme/theme_provider.dart';
void main() {
void main() async {
WidgetsFlutterBinding.ensureInitialized();
runApp(const ProviderScope(child: App()));
// Pre-load preferences so the first frame uses the saved theme/accent.
final prefs = await SharedPreferences.getInstance();
runApp(
ProviderScope(
overrides: [savedPrefsProvider.overrideWithValue(prefs)],
child: const App(),
),
);
}

View File

@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
/// Accent colors matching the desktop Sprout app.
class AccentColor {
final String name;
final Color light;
final Color dark;
const AccentColor({
required this.name,
required this.light,
required this.dark,
});
}
const accentColors = [
AccentColor(name: 'Blue', light: Color(0xFF3B82F6), dark: Color(0xFF60A5FA)),
AccentColor(name: 'Cyan', light: Color(0xFF06B6D4), dark: Color(0xFF22D3EE)),
AccentColor(name: 'Green', light: Color(0xFF22C55E), dark: Color(0xFF4ADE80)),
AccentColor(
name: 'Orange',
light: Color(0xFFF97316),
dark: Color(0xFFFB923C),
),
AccentColor(name: 'Red', light: Color(0xFFEF4444), dark: Color(0xFFF87171)),
AccentColor(name: 'Pink', light: Color(0xFFEC4899), dark: Color(0xFFF472B6)),
AccentColor(
name: 'Purple',
light: Color(0xFFA855F7),
dark: Color(0xFFC084FC),
),
AccentColor(
name: 'Indigo',
light: Color(0xFF6366F1),
dark: Color(0xFF818CF8),
),
];
/// Default: Catppuccin Mauve (the current primary).
const defaultAccentIndex = -1; // -1 means "use theme default (Mauve)"

View File

@@ -6,240 +6,279 @@ import 'color_scheme.dart';
import 'grid.dart';
import 'text_theme.dart';
/// Border radius constants matching desktop shadcn "New York" style.
/// Desktop uses --radius: 0.625rem (10px) as base:
/// lg = 10px, md = 8px, sm = 6px
class Radii {
static const double lg = 10.0;
static const double md = 8.0;
static const double sm = 6.0;
static const double dialog = 24.0; // desktop uses rounded-3xl for dialogs
}
class AppTheme {
static ThemeData get lightTheme {
static ThemeData light({ColorScheme? colorScheme}) {
final scheme = colorScheme ?? lightColorScheme;
const appColors = AppColors(
success: Color(0xFF40A02B), // Latte Green
warning: Color(0xFFDF8E1D), // Latte Yellow
accent: Color(0xFF1E66F5), // Latte Blue
);
return ThemeData(
useMaterial3: true,
colorScheme: lightColorScheme,
extensions: [appColors],
textTheme: textTheme,
appBarTheme: AppBarTheme(
backgroundColor: lightColorScheme.surface,
foregroundColor: lightColorScheme.onSurface,
surfaceTintColor: Colors.transparent,
elevation: 0,
titleTextStyle: textTheme.titleMedium?.copyWith(
color: lightColorScheme.onSurface,
),
systemOverlayStyle: const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
statusBarBrightness: Brightness.light,
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: lightColorScheme.primary,
foregroundColor: lightColorScheme.onPrimary,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
backgroundColor: lightColorScheme.surface,
foregroundColor: lightColorScheme.onSurface,
side: BorderSide(color: lightColorScheme.outline, width: 1),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: lightColorScheme.onSurface,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
),
cardTheme: CardThemeData(
color: lightColorScheme.surfaceContainerHighest,
margin: EdgeInsets.zero,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: lightColorScheme.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: lightColorScheme.outline),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: lightColorScheme.outline),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: lightColorScheme.primary),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: lightColorScheme.error),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
progressIndicatorTheme: ProgressIndicatorThemeData(
strokeWidth: 2,
color: appColors.accent,
circularTrackColor: lightColorScheme.onSurfaceVariant.withValues(
alpha: 0.2,
),
),
listTileTheme: ListTileThemeData(
titleTextStyle: textTheme.titleSmall?.copyWith(
color: lightColorScheme.onSurface,
),
subtitleTextStyle: textTheme.bodyMedium?.copyWith(
color: lightColorScheme.secondary,
),
iconColor: lightColorScheme.secondary,
contentPadding: const EdgeInsets.symmetric(horizontal: Grid.twelve),
minVerticalPadding: Grid.twelve,
horizontalTitleGap: Grid.twelve,
),
chipTheme: ChipThemeData(
labelStyle: textTheme.bodySmall?.copyWith(
color: lightColorScheme.secondary,
),
shape: RoundedRectangleBorder(
side: BorderSide.none,
borderRadius: BorderRadius.circular(8),
),
side: BorderSide.none,
padding: const EdgeInsets.symmetric(horizontal: 8),
labelPadding: EdgeInsets.zero,
),
return _buildTheme(
scheme: scheme,
appColors: appColors,
brightness: Brightness.light,
statusBarIconBrightness: Brightness.dark,
statusBarBrightness: Brightness.light,
);
}
static ThemeData get darkTheme {
static ThemeData dark({ColorScheme? colorScheme}) {
final scheme = colorScheme ?? darkColorScheme;
const appColors = AppColors(
success: Color(0xFFA6DA95), // Macchiato Green
warning: Color(0xFFEED49F), // Macchiato Yellow
accent: Color(0xFF8AADF4), // Macchiato Blue
);
return _buildTheme(
scheme: scheme,
appColors: appColors,
brightness: Brightness.dark,
statusBarIconBrightness: Brightness.light,
statusBarBrightness: Brightness.dark,
);
}
/// Legacy getters for backwards compatibility.
static ThemeData get lightTheme => light();
static ThemeData get darkTheme => dark();
static ThemeData _buildTheme({
required ColorScheme scheme,
required AppColors appColors,
required Brightness brightness,
required Brightness statusBarIconBrightness,
required Brightness statusBarBrightness,
}) {
return ThemeData(
useMaterial3: true,
colorScheme: darkColorScheme,
colorScheme: scheme,
extensions: [appColors],
textTheme: textTheme,
appBarTheme: AppBarTheme(
backgroundColor: darkColorScheme.surface,
foregroundColor: darkColorScheme.onSurface,
backgroundColor: scheme.surface,
foregroundColor: scheme.onSurface,
surfaceTintColor: Colors.transparent,
elevation: 0,
titleTextStyle: textTheme.titleMedium?.copyWith(
color: darkColorScheme.onSurface,
color: scheme.onSurface,
),
systemOverlayStyle: const SystemUiOverlayStyle(
systemOverlayStyle: SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
statusBarBrightness: Brightness.dark,
statusBarIconBrightness: statusBarIconBrightness,
statusBarBrightness: statusBarBrightness,
),
),
// Buttons: desktop uses rounded-md (8px), h-9 (36px), px-4 (16px)
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: darkColorScheme.primary,
foregroundColor: darkColorScheme.onPrimary,
backgroundColor: scheme.primary,
foregroundColor: scheme.onPrimary,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
minimumSize: const Size(0, 36),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
borderRadius: BorderRadius.circular(Radii.md),
),
textStyle: textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
minimumSize: const Size(0, 36),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.md),
),
textStyle: textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
backgroundColor: darkColorScheme.surface,
foregroundColor: darkColorScheme.onSurface,
side: BorderSide(color: darkColorScheme.outline, width: 1),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
backgroundColor: scheme.surface,
foregroundColor: scheme.onSurface,
side: BorderSide(color: scheme.outline, width: 1),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
minimumSize: const Size(0, 36),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
borderRadius: BorderRadius.circular(Radii.md),
),
textStyle: textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: darkColorScheme.onSurface,
foregroundColor: scheme.onSurface,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
minimumSize: const Size(0, 36),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.md),
),
textStyle: textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
),
// Cards: desktop uses rounded-lg (10px), flat, no elevation
cardTheme: CardThemeData(
color: darkColorScheme.surfaceContainerHighest,
color: scheme.surfaceContainerHighest,
margin: EdgeInsets.zero,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.lg),
),
),
// Inputs: desktop uses outlined style, rounded-md (8px), h-9 (36px)
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: darkColorScheme.surfaceContainerHighest,
filled: false,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: darkColorScheme.outline),
borderRadius: BorderRadius.circular(Radii.md),
borderSide: BorderSide(color: scheme.outline),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: darkColorScheme.outline),
borderRadius: BorderRadius.circular(Radii.md),
borderSide: BorderSide(color: scheme.outline),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: darkColorScheme.primary),
borderRadius: BorderRadius.circular(Radii.md),
borderSide: BorderSide(color: scheme.primary),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: darkColorScheme.error),
borderRadius: BorderRadius.circular(Radii.md),
borderSide: BorderSide(color: scheme.error),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(Radii.md),
borderSide: BorderSide(color: scheme.error),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
horizontal: 12,
vertical: 10,
),
isDense: true,
),
// Dialogs: desktop uses rounded-3xl (24px), custom overlay
dialogTheme: DialogThemeData(
backgroundColor: scheme.surface,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.dialog),
side: BorderSide(color: scheme.outline),
),
titleTextStyle: textTheme.titleLarge?.copyWith(
color: scheme.onSurface,
fontSize: 18,
fontWeight: FontWeight.w600,
letterSpacing: -0.3,
),
contentTextStyle: textTheme.bodyMedium?.copyWith(
color: scheme.onSurfaceVariant,
),
),
progressIndicatorTheme: ProgressIndicatorThemeData(
strokeWidth: 2,
color: appColors.accent,
circularTrackColor: darkColorScheme.onSurfaceVariant.withValues(
alpha: 0.2,
),
color: scheme.primary,
circularTrackColor: scheme.onSurfaceVariant.withValues(alpha: 0.2),
),
listTileTheme: ListTileThemeData(
titleTextStyle: textTheme.titleSmall?.copyWith(
color: darkColorScheme.onSurface,
),
titleTextStyle: textTheme.titleSmall?.copyWith(color: scheme.onSurface),
subtitleTextStyle: textTheme.bodyMedium?.copyWith(
color: darkColorScheme.secondary,
color: scheme.secondary,
),
iconColor: darkColorScheme.secondary,
iconColor: scheme.secondary,
contentPadding: const EdgeInsets.symmetric(horizontal: Grid.twelve),
minVerticalPadding: Grid.twelve,
horizontalTitleGap: Grid.twelve,
),
chipTheme: ChipThemeData(
labelStyle: textTheme.bodySmall?.copyWith(
color: darkColorScheme.secondary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.md),
),
),
// Chips: desktop uses rounded-sm (6px)
chipTheme: ChipThemeData(
labelStyle: textTheme.bodySmall?.copyWith(color: scheme.secondary),
shape: RoundedRectangleBorder(
side: BorderSide.none,
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(Radii.sm),
),
side: BorderSide.none,
padding: const EdgeInsets.symmetric(horizontal: 8),
labelPadding: EdgeInsets.zero,
),
// Popups/menus: desktop uses rounded-md (8px)
popupMenuTheme: PopupMenuThemeData(
color: scheme.surface,
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.md),
side: BorderSide(color: scheme.outline),
),
),
// Bottom sheet: match dialog radius
bottomSheetTheme: BottomSheetThemeData(
backgroundColor: scheme.surface,
elevation: 0,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(Radii.dialog),
),
),
),
// Tooltips: desktop uses rounded-md, primary bg
tooltipTheme: TooltipThemeData(
decoration: BoxDecoration(
color: scheme.primary,
borderRadius: BorderRadius.circular(Radii.md),
),
textStyle: textTheme.bodySmall?.copyWith(
color: scheme.onPrimary,
fontSize: 12,
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
),
// Divider
dividerTheme: DividerThemeData(
color: scheme.outline,
thickness: 1,
space: 1,
),
// Snackbar
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.md),
),
),
);
}
}

View File

@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'accent_colors.dart';
// Catppuccin Latte (mauve accent) — matches Sprout desktop light theme
const lightColorScheme = ColorScheme(
brightness: Brightness.light,
@@ -65,3 +67,34 @@ const darkColorScheme = ColorScheme(
surfaceTint: Color(0xFFC6A0F6), // Macchiato Mauve
surfaceContainerHighest: Color(0xFF1E2030), // Macchiato Mantle
);
/// Compute a contrast-safe onPrimary color for a given accent.
/// Uses WCAG contrast ratio (higher ratio wins) instead of a simple luminance
/// cutoff, so colors like Blue (#3B82F6) correctly get black text (5.7:1)
/// rather than white (3.7:1).
Color _contrastForeground(Color bg) {
final lum = bg.computeLuminance();
// WCAG contrast ratio: (L1 + 0.05) / (L2 + 0.05), L1 >= L2
final contrastWithBlack = (lum + 0.05) / 0.05; // black luminance = 0
final contrastWithWhite = 1.05 / (lum + 0.05); // white luminance = 1
return contrastWithBlack >= contrastWithWhite
? const Color(0xFF000000)
: const Color(0xFFFFFFFF);
}
/// Returns a [ColorScheme] with the given accent applied as primary.
/// If [accentIndex] is [defaultAccentIndex], returns the base scheme unchanged.
ColorScheme applyAccent(ColorScheme base, int accentIndex) {
if (accentIndex == defaultAccentIndex ||
accentIndex < 0 ||
accentIndex >= accentColors.length) {
return base;
}
final accent = accentColors[accentIndex];
final color = base.brightness == Brightness.light
? accent.light
: accent.dark;
final onColor = _contrastForeground(color);
return base.copyWith(primary: color, onPrimary: onColor, surfaceTint: color);
}

View File

@@ -1,71 +1,95 @@
import 'package:flutter/material.dart';
const _fontFamily = 'Geist';
const textTheme = TextTheme(
displayLarge: TextStyle(
fontFamily: _fontFamily,
fontSize: 52,
fontWeight: FontWeight.w400,
height: 1.15,
),
displayMedium: TextStyle(
fontFamily: _fontFamily,
fontSize: 44,
fontWeight: FontWeight.w400,
height: 1.15,
),
displaySmall: TextStyle(
fontFamily: _fontFamily,
fontSize: 36,
fontWeight: FontWeight.w400,
height: 1.15,
),
headlineLarge: TextStyle(
fontFamily: _fontFamily,
fontSize: 32,
fontWeight: FontWeight.w600,
height: 1.15,
),
headlineMedium: TextStyle(
fontFamily: _fontFamily,
fontSize: 28,
fontWeight: FontWeight.w600,
height: 1.15,
),
headlineSmall: TextStyle(
fontFamily: _fontFamily,
fontSize: 24,
fontWeight: FontWeight.w600,
height: 1.15,
),
titleLarge: TextStyle(
fontFamily: _fontFamily,
fontSize: 24,
fontWeight: FontWeight.w400,
height: 1.15,
),
titleMedium: TextStyle(
fontFamily: _fontFamily,
fontSize: 20,
fontWeight: FontWeight.w500,
height: 1.15,
),
titleSmall: TextStyle(
fontFamily: _fontFamily,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.15,
),
labelLarge: TextStyle(
fontFamily: _fontFamily,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.15,
),
labelMedium: TextStyle(
fontFamily: _fontFamily,
fontSize: 14,
fontWeight: FontWeight.w500,
height: 1.15,
),
labelSmall: TextStyle(
fontFamily: _fontFamily,
fontSize: 11,
fontWeight: FontWeight.w500,
height: 1.15,
),
bodyLarge: TextStyle(fontSize: 16, fontWeight: FontWeight.w400, height: 1.25),
bodyLarge: TextStyle(
fontFamily: _fontFamily,
fontSize: 16,
fontWeight: FontWeight.w400,
height: 1.25,
),
bodyMedium: TextStyle(
fontFamily: _fontFamily,
fontSize: 14,
fontWeight: FontWeight.w400,
height: 1.43,
),
bodySmall: TextStyle(fontSize: 12, fontWeight: FontWeight.w400),
bodySmall: TextStyle(
fontFamily: _fontFamily,
fontSize: 12,
fontWeight: FontWeight.w400,
),
);

View File

@@ -1,5 +1,7 @@
export 'accent_colors.dart';
export 'app_colors.dart';
export 'app_theme.dart';
export 'color_scheme.dart';
export 'grid.dart';
export 'theme_extensions.dart';
export 'theme_provider.dart';

View File

@@ -1,24 +1,44 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'accent_colors.dart';
const _themeModeKey = 'sprout_theme_mode';
const _accentKey = 'sprout_accent_color';
/// Pre-loaded SharedPreferences instance, overridden in main().
final savedPrefsProvider = Provider<SharedPreferences>(
(_) => throw UnimplementedError('Must be overridden'),
);
class ThemeNotifier extends Notifier<ThemeMode> {
@override
ThemeMode build() => ThemeMode.system;
ThemeMode build() {
final prefs = ref.read(savedPrefsProvider);
final stored = prefs.getString(_themeModeKey);
if (stored != null) {
return ThemeMode.values.where((m) => m.name == stored).firstOrNull ??
ThemeMode.system;
}
return ThemeMode.system;
}
void setThemeMode(ThemeMode themeMode) {
state = themeMode;
ref.read(savedPrefsProvider).setString(_themeModeKey, themeMode.name);
}
void toggleTheme() {
switch (state) {
case ThemeMode.light:
state = ThemeMode.dark;
setThemeMode(ThemeMode.dark);
break;
case ThemeMode.dark:
state = ThemeMode.system;
setThemeMode(ThemeMode.system);
break;
case ThemeMode.system:
state = ThemeMode.light;
setThemeMode(ThemeMode.light);
break;
}
}
@@ -27,3 +47,22 @@ class ThemeNotifier extends Notifier<ThemeMode> {
final themeProvider = NotifierProvider<ThemeNotifier, ThemeMode>(
ThemeNotifier.new,
);
/// Tracks the selected accent color index.
/// -1 means "use theme default (Catppuccin Mauve)".
class AccentNotifier extends Notifier<int> {
@override
int build() {
final prefs = ref.read(savedPrefsProvider);
return prefs.getInt(_accentKey) ?? defaultAccentIndex;
}
void setAccent(int index) {
state = index;
ref.read(savedPrefsProvider).setInt(_accentKey, index);
}
}
final accentProvider = NotifierProvider<AccentNotifier, int>(
AccentNotifier.new,
);

View File

@@ -672,6 +672,62 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.28.0"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
url: "https://pub.dev"
source: hosted
version: "2.5.5"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
url: "https://pub.dev"
source: hosted
version: "2.4.23"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shelf:
dependency: transitive
description:

View File

@@ -15,6 +15,7 @@ dependencies:
http: ^1.4.0
flutter_secure_storage: ^9.2.4
mobile_scanner: ^6.0.2
shared_preferences: ^2.5.5
dev_dependencies:
flutter_test:
@@ -26,3 +27,14 @@ dev_dependencies:
flutter:
uses-material-design: true
fonts:
- family: Geist
fonts:
- asset: assets/fonts/Geist-Variable.ttf
- asset: assets/fonts/Geist-Italic-Variable.ttf
style: italic
- family: GeistMono
fonts:
- asset: assets/fonts/GeistMono-Variable.ttf
- asset: assets/fonts/GeistMono-Italic-Variable.ttf
style: italic

View File

@@ -62,9 +62,8 @@ void main() {
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);
// Section header shows channel count
expect(find.text('2'), findsOneWidget);
});
testWidgets('shows empty state when no channels', (tester) async {

View File

@@ -1,15 +1,23 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:sprout_mobile/app.dart';
import 'package:sprout_mobile/shared/auth/auth.dart';
import 'package:sprout_mobile/shared/theme/theme_provider.dart';
void main() {
testWidgets('App renders pairing page when unauthenticated', (
WidgetTester tester,
) async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
ProviderScope(
overrides: [authProvider.overrideWith(() => _FakeAuthNotifier())],
overrides: [
authProvider.overrideWith(() => _FakeAuthNotifier()),
savedPrefsProvider.overrideWithValue(prefs),
],
child: const App(),
),
);