mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
BIN
mobile/assets/fonts/Geist-Italic-Variable.ttf
Normal file
BIN
mobile/assets/fonts/Geist-Italic-Variable.ttf
Normal file
Binary file not shown.
BIN
mobile/assets/fonts/Geist-Variable.ttf
Normal file
BIN
mobile/assets/fonts/Geist-Variable.ttf
Normal file
Binary file not shown.
BIN
mobile/assets/fonts/GeistMono-Italic-Variable.ttf
Normal file
BIN
mobile/assets/fonts/GeistMono-Italic-Variable.ttf
Normal file
Binary file not shown.
BIN
mobile/assets/fonts/GeistMono-Variable.ttf
Normal file
BIN
mobile/assets/fonts/GeistMono-Variable.ttf
Normal file
Binary file not shown.
@@ -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
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
40
mobile/lib/shared/theme/accent_colors.dart
Normal file
40
mobile/lib/shared/theme/accent_colors.dart
Normal 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)"
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user