diff --git a/mobile/assets/fonts/Geist-Italic-Variable.ttf b/mobile/assets/fonts/Geist-Italic-Variable.ttf new file mode 100644 index 00000000000..a7399e1cda6 Binary files /dev/null and b/mobile/assets/fonts/Geist-Italic-Variable.ttf differ diff --git a/mobile/assets/fonts/Geist-Variable.ttf b/mobile/assets/fonts/Geist-Variable.ttf new file mode 100644 index 00000000000..bed807e86f1 Binary files /dev/null and b/mobile/assets/fonts/Geist-Variable.ttf differ diff --git a/mobile/assets/fonts/GeistMono-Italic-Variable.ttf b/mobile/assets/fonts/GeistMono-Italic-Variable.ttf new file mode 100644 index 00000000000..100dc33d8da Binary files /dev/null and b/mobile/assets/fonts/GeistMono-Italic-Variable.ttf differ diff --git a/mobile/assets/fonts/GeistMono-Variable.ttf b/mobile/assets/fonts/GeistMono-Variable.ttf new file mode 100644 index 00000000000..e8d645abc3d Binary files /dev/null and b/mobile/assets/fonts/GeistMono-Variable.ttf differ diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 6b85699de66..b7cca0a4283 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -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 diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index e1c0fa68b2f..e843fa09f70 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -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(), diff --git a/mobile/lib/features/channels/channel.dart b/mobile/lib/features/channels/channel.dart index dea3a0f49e2..5a828a43b4b 100644 --- a/mobile/lib/features/channels/channel.dart +++ b/mobile/lib/features/channels/channel.dart @@ -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 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'; diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index c8541c999b2..560ebf3b4c5 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -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,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 { diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index 62c0d953dae..164535bd3d6 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -19,15 +19,7 @@ class ChannelsNotifier extends AsyncNotifier> { .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; } diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index 87bcdb37158..9bc532437da 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -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); + } +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 116b1e80206..833d48b207c 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -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(), + ), + ); } diff --git a/mobile/lib/shared/theme/accent_colors.dart b/mobile/lib/shared/theme/accent_colors.dart new file mode 100644 index 00000000000..9a2e740792a --- /dev/null +++ b/mobile/lib/shared/theme/accent_colors.dart @@ -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)" diff --git a/mobile/lib/shared/theme/app_theme.dart b/mobile/lib/shared/theme/app_theme.dart index 5ee7f3d4fd8..7e6b8b71fbe 100644 --- a/mobile/lib/shared/theme/app_theme.dart +++ b/mobile/lib/shared/theme/app_theme.dart @@ -6,239 +6,278 @@ 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 _buildTheme( + scheme: scheme, + appColors: appColors, + brightness: Brightness.light, + statusBarIconBrightness: Brightness.dark, + statusBarBrightness: Brightness.light, + ); + } + + 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: lightColorScheme, + colorScheme: scheme, extensions: [appColors], textTheme: textTheme, appBarTheme: AppBarTheme( - backgroundColor: lightColorScheme.surface, - foregroundColor: lightColorScheme.onSurface, + backgroundColor: scheme.surface, + foregroundColor: scheme.onSurface, surfaceTintColor: Colors.transparent, elevation: 0, titleTextStyle: textTheme.titleMedium?.copyWith( - color: lightColorScheme.onSurface, + color: scheme.onSurface, ), - systemOverlayStyle: const SystemUiOverlayStyle( + systemOverlayStyle: SystemUiOverlayStyle( statusBarColor: Colors.transparent, - statusBarIconBrightness: Brightness.dark, - statusBarBrightness: Brightness.light, + statusBarIconBrightness: statusBarIconBrightness, + statusBarBrightness: statusBarBrightness, ), ), + + // Buttons: desktop uses rounded-md (8px), h-9 (36px), px-4 (16px) elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( - backgroundColor: lightColorScheme.primary, - foregroundColor: lightColorScheme.onPrimary, + backgroundColor: scheme.primary, + foregroundColor: scheme.onPrimary, + 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, + ), + ), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( 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, ), ), ), 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), + 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: lightColorScheme.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: lightColorScheme.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: lightColorScheme.surfaceContainerHighest, + filled: false, border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: lightColorScheme.outline), + borderRadius: BorderRadius.circular(Radii.md), + borderSide: BorderSide(color: scheme.outline), ), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: lightColorScheme.outline), + borderRadius: BorderRadius.circular(Radii.md), + borderSide: BorderSide(color: scheme.outline), ), focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: lightColorScheme.primary), + borderRadius: BorderRadius.circular(Radii.md), + borderSide: BorderSide(color: scheme.primary), ), errorBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: lightColorScheme.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: lightColorScheme.onSurfaceVariant.withValues( - alpha: 0.2, - ), + color: scheme.primary, + circularTrackColor: scheme.onSurfaceVariant.withValues(alpha: 0.2), ), + listTileTheme: ListTileThemeData( - titleTextStyle: textTheme.titleSmall?.copyWith( - color: lightColorScheme.onSurface, - ), + titleTextStyle: textTheme.titleSmall?.copyWith(color: scheme.onSurface), subtitleTextStyle: textTheme.bodyMedium?.copyWith( - color: lightColorScheme.secondary, + color: scheme.secondary, ), - iconColor: lightColorScheme.secondary, + iconColor: scheme.secondary, contentPadding: const EdgeInsets.symmetric(horizontal: Grid.twelve), minVerticalPadding: Grid.twelve, horizontalTitleGap: Grid.twelve, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(Radii.md), + ), ), + + // Chips: desktop uses rounded-sm (6px) chipTheme: ChipThemeData( - labelStyle: textTheme.bodySmall?.copyWith( - color: lightColorScheme.secondary, - ), + 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, ), - ); - } - - static ThemeData get darkTheme { - const appColors = AppColors( - success: Color(0xFFA6DA95), // Macchiato Green - warning: Color(0xFFEED49F), // Macchiato Yellow - accent: Color(0xFF8AADF4), // Macchiato Blue - ); - return ThemeData( - useMaterial3: true, - colorScheme: darkColorScheme, - extensions: [appColors], - textTheme: textTheme, - appBarTheme: AppBarTheme( - backgroundColor: darkColorScheme.surface, - foregroundColor: darkColorScheme.onSurface, - surfaceTintColor: Colors.transparent, - elevation: 0, - titleTextStyle: textTheme.titleMedium?.copyWith( - color: darkColorScheme.onSurface, - ), - systemOverlayStyle: const SystemUiOverlayStyle( - statusBarColor: Colors.transparent, - statusBarIconBrightness: Brightness.light, - statusBarBrightness: Brightness.dark, - ), - ), - elevatedButtonTheme: ElevatedButtonThemeData( - style: ElevatedButton.styleFrom( - backgroundColor: darkColorScheme.primary, - foregroundColor: darkColorScheme.onPrimary, - elevation: 0, - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), - ), + // 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), ), ), - 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), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), + + // Bottom sheet: match dialog radius + bottomSheetTheme: BottomSheetThemeData( + backgroundColor: scheme.surface, + elevation: 0, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(Radii.dialog), ), ), ), - textButtonTheme: TextButtonThemeData( - style: TextButton.styleFrom( - foregroundColor: darkColorScheme.onSurface, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - ), - ), - cardTheme: CardThemeData( - color: darkColorScheme.surfaceContainerHighest, - margin: EdgeInsets.zero, - elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - ), - inputDecorationTheme: InputDecorationTheme( - filled: true, - fillColor: darkColorScheme.surfaceContainerHighest, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: darkColorScheme.outline), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: darkColorScheme.outline), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: darkColorScheme.primary), - ), - errorBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: darkColorScheme.error), + + // Tooltips: desktop uses rounded-md, primary bg + tooltipTheme: TooltipThemeData( + decoration: BoxDecoration( + color: scheme.primary, + borderRadius: BorderRadius.circular(Radii.md), ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, + textStyle: textTheme.bodySmall?.copyWith( + color: scheme.onPrimary, + fontSize: 12, ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), ), - progressIndicatorTheme: ProgressIndicatorThemeData( - strokeWidth: 2, - color: appColors.accent, - circularTrackColor: darkColorScheme.onSurfaceVariant.withValues( - alpha: 0.2, - ), - ), - listTileTheme: ListTileThemeData( - titleTextStyle: textTheme.titleSmall?.copyWith( - color: darkColorScheme.onSurface, - ), - subtitleTextStyle: textTheme.bodyMedium?.copyWith( - color: darkColorScheme.secondary, - ), - iconColor: darkColorScheme.secondary, - contentPadding: const EdgeInsets.symmetric(horizontal: Grid.twelve), - minVerticalPadding: Grid.twelve, - horizontalTitleGap: Grid.twelve, + + // Divider + dividerTheme: DividerThemeData( + color: scheme.outline, + thickness: 1, + space: 1, ), - chipTheme: ChipThemeData( - labelStyle: textTheme.bodySmall?.copyWith( - color: darkColorScheme.secondary, - ), + + // Snackbar + snackBarTheme: SnackBarThemeData( + behavior: SnackBarBehavior.floating, shape: RoundedRectangleBorder( - side: BorderSide.none, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(Radii.md), ), - side: BorderSide.none, - padding: const EdgeInsets.symmetric(horizontal: 8), - labelPadding: EdgeInsets.zero, ), ); } diff --git a/mobile/lib/shared/theme/color_scheme.dart b/mobile/lib/shared/theme/color_scheme.dart index f576db3efe3..db991375bc2 100644 --- a/mobile/lib/shared/theme/color_scheme.dart +++ b/mobile/lib/shared/theme/color_scheme.dart @@ -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); +} diff --git a/mobile/lib/shared/theme/text_theme.dart b/mobile/lib/shared/theme/text_theme.dart index 8c260e64c46..b80d655caeb 100644 --- a/mobile/lib/shared/theme/text_theme.dart +++ b/mobile/lib/shared/theme/text_theme.dart @@ -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, + ), ); diff --git a/mobile/lib/shared/theme/theme.dart b/mobile/lib/shared/theme/theme.dart index 2fb4608ecc4..7dc6996fde6 100644 --- a/mobile/lib/shared/theme/theme.dart +++ b/mobile/lib/shared/theme/theme.dart @@ -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'; diff --git a/mobile/lib/shared/theme/theme_provider.dart b/mobile/lib/shared/theme/theme_provider.dart index 99dce9741b9..848af37ec08 100644 --- a/mobile/lib/shared/theme/theme_provider.dart +++ b/mobile/lib/shared/theme/theme_provider.dart @@ -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( + (_) => throw UnimplementedError('Must be overridden'), +); class ThemeNotifier extends Notifier { @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 { final themeProvider = NotifierProvider( ThemeNotifier.new, ); + +/// Tracks the selected accent color index. +/// -1 means "use theme default (Catppuccin Mauve)". +class AccentNotifier extends Notifier { + @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.new, +); diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 5746be37c9c..d3bd84cda52 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -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: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index c0b5ed487bc..2704af93baf 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -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 diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 05972861761..a410f167be0 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -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 { diff --git a/mobile/test/widget_test.dart b/mobile/test/widget_test.dart index 7328e5cce7b..9f2cb530d70 100644 --- a/mobile/test/widget_test.dart +++ b/mobile/test/widget_test.dart @@ -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(), ), );