Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:spark/src/core/design_system/tokens/shadows.dart';

enum AppSurfaceVariant { outlined, raised }

class AppSurface extends StatelessWidget {
const AppSurface({
required this.child,
super.key,
this.variant = AppSurfaceVariant.outlined,
this.margin = EdgeInsets.zero,
this.padding = EdgeInsets.zero,
});

final Widget child;
final AppSurfaceVariant variant;
final EdgeInsetsGeometry margin;
final EdgeInsetsGeometry padding;

@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
final borderRadius = BorderRadius.circular(16);

return Padding(
padding: margin,
child: DecoratedBox(
decoration: BoxDecoration(
color: isDark
? colorScheme.surfaceContainerHighest
: colorScheme.surface,
borderRadius: borderRadius,
border: Border.all(color: colorScheme.outline),
boxShadow: variant == AppSurfaceVariant.raised
? const [AppShadows.shadowXs]
: null,
),
child: ClipRRect(
borderRadius: borderRadius,
child: Padding(padding: padding, child: child),
),
),
);
}
}
116 changes: 116 additions & 0 deletions lib/src/core/design_system/components/molecules/app_choice_group.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import 'package:spark/src/core/design_system/components/atoms/buttons/interactive_pressable.dart';
import 'package:spark/src/core/design_system/tokens/typography.dart';

@immutable
class AppChoiceOption<T> {
const AppChoiceOption({
required this.value,
required this.label,
this.enabled = true,
});

final T value;
final String label;
final bool enabled;
}

class AppChoiceGroup<T> extends StatelessWidget {
const AppChoiceGroup({
required this.value,
required this.options,
required this.onChanged,
super.key,
this.enabled = true,
}) : assert(options.length > 1);

final T value;
final List<AppChoiceOption<T>> options;
final ValueChanged<T> onChanged;
final bool enabled;

@override
Widget build(BuildContext context) {
return Row(
spacing: 6,
children: [
for (final option in options)
Expanded(
child: _ChoiceButton<T>(
option: option,
isSelected: option.value == value,
enabled: enabled && option.enabled,
onSelected: onChanged,
),
),
],
);
}
}

class _ChoiceButton<T> extends StatelessWidget {
const _ChoiceButton({
required this.option,
required this.isSelected,
required this.enabled,
required this.onSelected,
});

final AppChoiceOption<T> option;
final bool isSelected;
final bool enabled;
final ValueChanged<T> onSelected;

@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final onTap = enabled ? () => onSelected(option.value) : null;
final backgroundColor = enabled
? isSelected
? colorScheme.primary
: colorScheme.surface
: colorScheme.onSurface.withValues(alpha: 0.12);
final foregroundColor = enabled
? isSelected
? colorScheme.onPrimary
: colorScheme.onSurface
: colorScheme.onSurface.withValues(alpha: 0.38);
final borderRadius = BorderRadius.circular(12);

return Semantics(
label: option.label,
button: true,
enabled: enabled,
selected: isSelected,
inMutuallyExclusiveGroup: true,
onTap: onTap,
child: ExcludeSemantics(
child: InteractivePressable(
onTap: onTap,
borderRadius: borderRadius,
child: Material(
color: backgroundColor,
elevation: isSelected && enabled ? 2 : 0,
shape: RoundedRectangleBorder(
borderRadius: borderRadius,
side: BorderSide(color: colorScheme.outline, width: 0.5),
),
clipBehavior: Clip.antiAlias,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 40),
child: Align(
child: Text(
option.label,
overflow: TextOverflow.ellipsis,
style: AppTypography.textExtraSmallBold.copyWith(
color: foregroundColor,
),
),
),
),
),
),
),
);
}
}
34 changes: 17 additions & 17 deletions lib/src/core/design_system/components/molecules/profile_avatar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class ProfileAvatar extends StatelessWidget {
this.onTap,
this.showAddButton = false,
this.onAddTap,
this.avatarBuilder,
});

final String? avatarUrl;
Expand All @@ -23,6 +24,7 @@ class ProfileAvatar extends StatelessWidget {
final VoidCallback? onTap;
final bool showAddButton;
final VoidCallback? onAddTap;
final Widget Function(Widget avatar)? avatarBuilder;

@override
Widget build(BuildContext context) {
Expand Down Expand Up @@ -111,24 +113,22 @@ class ProfileAvatar extends StatelessWidget {
required bool isDarkMode,
required double avatarSize,
}) {
if (avatarUrl != null && avatarUrl!.isNotEmpty) {
return ClipOval(
child: CachedNetworkImage(
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
imageUrl: avatarUrl!,
width: avatarSize,
height: avatarSize,
fit: BoxFit.cover,
placeholder: (context, url) =>
_buildPlaceholder(context, isDarkMode, avatarSize),
errorWidget: (context, url, error) =>
_buildPlaceholder(context, isDarkMode, avatarSize),
),
);
}
final avatar = avatarUrl != null && avatarUrl!.isNotEmpty
? CachedNetworkImage(
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
imageUrl: avatarUrl!,
width: avatarSize,
height: avatarSize,
fit: BoxFit.cover,
placeholder: (context, url) =>
_buildPlaceholder(context, isDarkMode, avatarSize),
errorWidget: (context, url, error) =>
_buildPlaceholder(context, isDarkMode, avatarSize),
)
: _buildPlaceholder(context, isDarkMode, avatarSize);

return _buildPlaceholder(context, isDarkMode, avatarSize);
return ClipOval(child: avatarBuilder?.call(avatar) ?? avatar);
}

Widget _buildPlaceholder(
Expand Down
20 changes: 13 additions & 7 deletions lib/src/core/design_system/components/molecules/profile_card.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class ProfileCard extends StatelessWidget {
this.onTap,
this.hasStories = false,
this.onAvatarTap,
this.avatarBuilder,
super.key,
});

Expand All @@ -37,6 +38,7 @@ class ProfileCard extends StatelessWidget {
VoidCallback? onTap,
bool hasStories = false,
VoidCallback? onAvatarTap,
Widget Function(Widget avatar)? avatarBuilder,
Key? key,
}) : this(
imageUrl: imageUrl,
Expand All @@ -51,6 +53,7 @@ class ProfileCard extends StatelessWidget {
onTap: onTap,
hasStories: hasStories,
onAvatarTap: onAvatarTap,
avatarBuilder: avatarBuilder,
key: key,
);

Expand All @@ -66,13 +69,22 @@ class ProfileCard extends StatelessWidget {
final VoidCallback? onTap;
final bool hasStories;
final VoidCallback? onAvatarTap;
final Widget Function(Widget avatar)? avatarBuilder;

@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final isDark = Theme.of(context).brightness == Brightness.dark;
final radius = BorderRadius.circular(AppShapes.squircleRadius);
final borderColor = isDark ? AppColors.grey800 : AppColors.grey200;
final avatar = ProfileAvatar(
avatarUrl: imageUrl.isNotEmpty ? imageUrl : null,
displayName: userName,
size: 36,
hasStories: hasStories,
onTap: onAvatarTap ?? onTap,
avatarBuilder: avatarBuilder,
);

final Widget content = ConstrainedBox(
constraints: const BoxConstraints(minHeight: 60),
Expand All @@ -97,13 +109,7 @@ class ProfileCard extends StatelessWidget {
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ProfileAvatar(
avatarUrl: imageUrl.isNotEmpty ? imageUrl : null,
displayName: userName,
size: 36,
hasStories: hasStories,
onTap: onAvatarTap ?? onTap,
),
avatar,
const SizedBox(width: 10),
Expanded(
child: Column(
Expand Down
Loading
Loading