💄 Better bottom nav, snowing animation and notification tile

💫 Animated snowing animation
This commit is contained in:
2025-12-22 23:28:38 +08:00
parent b0b227f36b
commit 09abe79f6a
3 changed files with 99 additions and 69 deletions

View File

@@ -212,48 +212,60 @@ class TabsScreen extends HookConsumerWidget {
child: MediaQuery.removePadding(
context: context,
removeTop: true,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
child: BottomAppBar(
height: 56,
padding: EdgeInsets.symmetric(horizontal: 24),
shape: AutomaticNotchedShape(
RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
child: Container(
decoration: BoxDecoration(
color: Colors.transparent,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
),
color: Theme.of(context).colorScheme.surface.withOpacity(0.8),
child: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: () {
final navItems = destinations.asMap().entries.map<Widget>((
entry,
) {
int index = entry.key;
NavigationDestination dest = entry.value;
return IconButton(
icon: dest.icon,
onPressed: () => onDestinationSelected(index),
color: index == currentIndex
? Theme.of(context).colorScheme.primary
: null,
],
),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
child: BottomAppBar(
height: 56,
padding: EdgeInsets.symmetric(horizontal: 24),
shape: AutomaticNotchedShape(
RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
),
),
color: Theme.of(context).colorScheme.surface.withOpacity(0.8),
child: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: () {
final navItems = destinations.asMap().entries.map<Widget>(
(entry) {
int index = entry.key;
NavigationDestination dest = entry.value;
return IconButton(
icon: dest.icon,
onPressed: () => onDestinationSelected(index),
color: index == currentIndex
? Theme.of(context).colorScheme.primary
: null,
);
},
).toList();
// Add mock item to leave space for FAB based on position
final gapIndex = switch (settings.fabPosition) {
'left' => 0,
'right' => navItems.length,
_ => navItems.length ~/ 2, // center
};
navItems.insert(
gapIndex,
SizedBox(
width: settings.fabPosition == 'center' ? 72 : 48,
),
);
}).toList();
// Add mock item to leave space for FAB based on position
final gapIndex = switch (settings.fabPosition) {
'left' => 0,
'right' => navItems.length,
_ => navItems.length ~/ 2, // center
};
navItems.insert(
gapIndex,
SizedBox(
width: settings.fabPosition == 'center' ? 72 : 48,
),
);
return navItems;
}(),
return navItems;
}(),
),
),
),
),

View File

@@ -35,7 +35,8 @@ class AppWrapper extends HookConsumerWidget {
final networkStateShowing = useState(false);
final wsNotifier = ref.watch(websocketStateProvider.notifier);
final websocketState = ref.watch(websocketStateProvider);
final showSnow = useState(false);
final isShowSnow = useState(false);
final isSnowGone = useState(false);
// Handle network status modal
if (websocketState == WebSocketState.duplicateDevice() &&
@@ -131,41 +132,56 @@ class AppWrapper extends HookConsumerWidget {
settings.festivalFeatures &&
now.month == 12 &&
(now.day >= 22 && now.day <= 28);
if (doesShowSnow) {
showSnow.value = true;
Future.delayed(const Duration(seconds: 10), () {
showSnow.value = false;
});
}
if (settings.firstLaunchAt == null) {
settingsNotifier.setFirstLaunchAt(now.toIso8601String());
} else if (!settings.askedReview) {
final launchAt = DateTime.parse(settings.firstLaunchAt!);
final daysSinceFirstLaunch = now.difference(launchAt).inDays;
if (daysSinceFirstLaunch >= 3 &&
!kIsWeb &&
(Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) {
final InAppReview inAppReview = InAppReview.instance;
Future(() async {
if (await inAppReview.isAvailable()) {
inAppReview.requestReview();
}
useEffect(() {
final now = DateTime.now();
if (doesShowSnow) {
isShowSnow.value = true;
Future.delayed(const Duration(seconds: 60), () {
if (!context.mounted) return;
isShowSnow.value = false;
Future.delayed(const Duration(seconds: 3), () {
if (!context.mounted) return;
isSnowGone.value = true;
});
});
settingsNotifier.setAskedReview(true);
}
}
if (settings.firstLaunchAt == null) {
settingsNotifier.setFirstLaunchAt(now.toIso8601String());
} else if (!settings.askedReview) {
final launchAt = DateTime.parse(settings.firstLaunchAt!);
final daysSinceFirstLaunch = now.difference(launchAt).inDays;
if (daysSinceFirstLaunch >= 3 &&
!kIsWeb &&
(Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) {
final InAppReview inAppReview = InAppReview.instance;
Future(() async {
if (await inAppReview.isAvailable()) {
inAppReview.requestReview();
}
});
settingsNotifier.setAskedReview(true);
}
}
return null;
}, []);
return TourTriggerWidget(
key: const Key("app_tour_trigger"),
child: Stack(
children: [
child,
if (showSnow.value)
if (doesShowSnow && !isSnowGone.value)
IgnorePointer(
child: SnowFallAnimation(
key: const Key("app_snow_animation"),
config: SnowfallConfig(numberOfSnowflakes: 50, speed: 1.0),
child: AnimatedOpacity(
opacity: isShowSnow.value ? 1 : 00,
duration: const Duration(seconds: 3),
child: SnowFallAnimation(
key: const Key("app_snow_animation"),
config: SnowfallConfig(numberOfSnowflakes: 50, speed: 1.0),
),
),
),
],

View File

@@ -81,14 +81,14 @@ class NotificationTile extends StatelessWidget {
style: compact
? Theme.of(context).textTheme.bodySmall
: Theme.of(context).textTheme.titleMedium,
maxLines: compact ? 2 : null,
maxLines: compact ? 1 : null,
overflow: compact ? TextOverflow.ellipsis : null,
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (notification.subtitle.isNotEmpty && !compact)
Text(notification.subtitle).bold(),
Text(notification.subtitle, maxLines: compact ? 3 : null).bold(),
Row(
spacing: 6,
children: [
@@ -114,7 +114,9 @@ class NotificationTile extends StatelessWidget {
],
).opacity(0.75).padding(bottom: compact ? 2 : 4),
MarkdownTextContent(
content: notification.content,
content: (compact && notification.content.length > 60)
? '${notification.content.substring(0, 60).replaceAll('\n', ' ')}...'
: notification.content,
textStyle:
(compact
? Theme.of(context).textTheme.bodySmall