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
5 changes: 5 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,11 @@ class _TechPieAppState extends State<TechPieApp> {
syncService: widget.syncService,
child: MaterialApp(
scaffoldMessengerKey: rootMessengerKey,
navigatorObservers: [FeedbackRouteObserver()],
builder: (context, child) => AdaptiveFeedbackHost(
key: adaptiveFeedbackHostKey,
child: child ?? const SizedBox.shrink(),
),
title: 'TechPie',
theme: widget.themeService.lightTheme,
darkTheme: widget.themeService.darkTheme,
Expand Down
20 changes: 20 additions & 0 deletions lib/services/theme_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,11 @@ class ThemeService extends ChangeNotifier {
scrolledUnderElevation: 0.5,
surfaceTintColor: Colors.transparent,
),
snackBarTheme: const SnackBarThemeData(
behavior: SnackBarBehavior.floating,
shape: _snackBarShape,
insetPadding: _snackBarInsets,
),
);
}

Expand All @@ -171,6 +176,13 @@ class ThemeService extends ChangeNotifier {
return _buildIosTheme(theme, brightness: Brightness.dark);
}

/// Snackbars float as rounded cards above the glass bottom nav (which is a
/// 56dp capsule with 8dp margins) instead of a full-width strip glued to it.
static const _snackBarShape = RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
);
static const _snackBarInsets = EdgeInsets.fromLTRB(12, 0, 12, 12);

ThemeData _buildDesktopTheme(ThemeData base) {
return base.copyWith(
appBarTheme: base.appBarTheme.copyWith(
Expand All @@ -179,6 +191,11 @@ class ThemeService extends ChangeNotifier {
scrolledUnderElevation: 0.5,
surfaceTintColor: Colors.transparent,
),
snackBarTheme: base.snackBarTheme.copyWith(
behavior: SnackBarBehavior.floating,
shape: _snackBarShape,
insetPadding: _snackBarInsets,
),
);
}

Expand Down Expand Up @@ -258,6 +275,9 @@ class ThemeService extends ChangeNotifier {
contentTextStyle: TextStyle(
color: isDark ? Colors.white : Colors.black,
),
behavior: SnackBarBehavior.floating,
shape: _snackBarShape,
insetPadding: _snackBarInsets,
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
Expand Down
269 changes: 240 additions & 29 deletions lib/widgets/adaptive_feedback.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,38 @@
import 'dart:async';

import 'package:flutter/material.dart';

import '../utils/platform.dart';
import 'app_shell/tg_bottom_nav_bar.dart';

final GlobalKey<ScaffoldMessengerState> rootMessengerKey =
GlobalKey<ScaffoldMessengerState>();

final GlobalKey<AdaptiveFeedbackHostState> adaptiveFeedbackHostKey =
GlobalKey<AdaptiveFeedbackHostState>();

/// Tracks how many page routes are stacked above the shell so the feedback
/// banner knows whether the bottom nav is on screen. Dialog/popup routes
/// don't count — the nav stays visible underneath them.
final ValueNotifier<int> _pageRouteDepth = ValueNotifier<int>(0);

class FeedbackRouteObserver extends NavigatorObserver {
@override
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
if (route is PageRoute) _pageRouteDepth.value++;
}

@override
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
if (route is PageRoute) _pageRouteDepth.value--;
}

@override
void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) {
if (route is PageRoute) _pageRouteDepth.value--;
}
}

enum AdaptiveFeedbackStyle { info, success, error }

void showAdaptiveFeedback({
Expand All @@ -13,39 +43,220 @@ void showAdaptiveFeedback({
String? actionLabel,
VoidCallback? onAction,
}) {
final messenger = context != null
? ScaffoldMessenger.maybeOf(context)
: rootMessengerKey.currentState;
if (messenger == null) return;

final theme = messenger.context.mounted
? Theme.of(messenger.context)
: ThemeData.fallback();
final feedbackStyle = _FeedbackStyle.from(theme, style);

messenger
..clearSnackBars()
..showSnackBar(
SnackBar(
backgroundColor: feedbackStyle.backgroundColor,
content: Row(
children: [
Icon(feedbackStyle.icon, color: feedbackStyle.foregroundColor),
const SizedBox(width: 12),
Expanded(
child: Text(
message,
style: TextStyle(color: feedbackStyle.foregroundColor),
),
adaptiveFeedbackHostKey.currentState?.show(
message: message,
style: style,
duration: duration,
actionLabel: actionLabel,
onAction: onAction,
);
}

/// App-level host for feedback banners, mounted above the [Navigator] (via
/// `MaterialApp.builder`).
///
/// A [SnackBar] can't be used here: the root [ScaffoldMessenger] renders the
/// current snackbar in BOTH the outgoing and incoming route's Scaffold during
/// a page transition, and since pages with and without the bottom nav give it
/// different bottom offsets, the same bar briefly shows twice ("ghosting").
/// Hosting a single banner above the Navigator means there is exactly one
/// instance, and its bottom clearance simply animates in step with the route
/// transition: flush-ish near the screen edge on pushed pages, floating above
/// the glass capsule (with a larger corner radius) on shell pages.
class AdaptiveFeedbackHost extends StatefulWidget {
final Widget child;

const AdaptiveFeedbackHost({super.key, required this.child});

@override
State<AdaptiveFeedbackHost> createState() => AdaptiveFeedbackHostState();
}

class AdaptiveFeedbackHostState extends State<AdaptiveFeedbackHost>
with SingleTickerProviderStateMixin {
// Matches the FadeThroughTransition page-transition timing so the banner
// glides together with the bottom nav's appearance.
static const _repositionDuration = Duration(milliseconds: 300);
static const _repositionCurve = Curves.easeInOutCubic;

late final AnimationController _controller;
late final Animation<double> _fade;
late final Animation<Offset> _slide;

_FeedbackEntry? _entry;
Timer? _dismissTimer;

@override
void initState() {
super.initState();
// The host is (re)built before the Navigator pushes its initial route
// (fresh start and hot restart alike), so the route count starts clean.
_pageRouteDepth.value = 0;
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 250),
reverseDuration: const Duration(milliseconds: 200),
);
_fade = CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic);
_slide = Tween<Offset>(
begin: const Offset(0, 0.4),
end: Offset.zero,
).animate(_fade);
}

void show({
required String message,
required AdaptiveFeedbackStyle style,
required Duration duration,
String? actionLabel,
VoidCallback? onAction,
}) {
_dismissTimer?.cancel();
setState(() {
_entry = _FeedbackEntry(
message: message,
style: style,
actionLabel: actionLabel,
onAction: onAction,
);
});
_controller.forward(from: _controller.value == 0 ? 0 : _controller.value);
_dismissTimer = Timer(duration, dismiss);
}

void dismiss() {
_dismissTimer?.cancel();
_dismissTimer = null;
unawaited(
_controller.reverse().whenComplete(() {
if (mounted && _dismissTimer == null) setState(() => _entry = null);
}),
);
}

@override
void dispose() {
_dismissTimer?.cancel();
_controller.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
return Stack(
children: [
widget.child,
if (_entry != null)
Positioned.fill(
child: ValueListenableBuilder<int>(
valueListenable: _pageRouteDepth,
builder: (context, depth, banner) {
final width = MediaQuery.sizeOf(context).width;
final safeBottom = MediaQuery.viewPaddingOf(context).bottom;
final navVisible = depth <= 1 && width < 600;
final navClearance = isIos()
? 52.0 + 12
: TgBottomNavBar.barHeight + 2 * TgBottomNavBar.margin;
final bottom = safeBottom + (navVisible ? navClearance : 12);
return IgnorePointer(
ignoring: _entry!.actionLabel == null,
child: AnimatedPadding(
duration: _repositionDuration,
curve: _repositionCurve,
padding: EdgeInsets.fromLTRB(12, 0, 12, bottom),
child: Align(
alignment: Alignment.bottomCenter,
child: FadeTransition(
opacity: _fade,
child: SlideTransition(
position: _slide,
child: _FeedbackBanner(
entry: _entry!,
rounded: navVisible,
repositionDuration: _repositionDuration,
repositionCurve: _repositionCurve,
),
),
),
),
),
);
},
),
),
],
);
}
}

class _FeedbackEntry {
const _FeedbackEntry({
required this.message,
required this.style,
this.actionLabel,
this.onAction,
});

final String message;
final AdaptiveFeedbackStyle style;
final String? actionLabel;
final VoidCallback? onAction;
}

class _FeedbackBanner extends StatelessWidget {
const _FeedbackBanner({
required this.entry,
required this.rounded,
required this.repositionDuration,
required this.repositionCurve,
});

final _FeedbackEntry entry;
final bool rounded;
final Duration repositionDuration;
final Curve repositionCurve;

@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final style = _FeedbackStyle.from(theme, entry.style);
final radius = BorderRadius.circular(rounded ? 16 : 10);

return AnimatedContainer(
duration: repositionDuration,
curve: repositionCurve,
decoration: BoxDecoration(
color: style.backgroundColor,
borderRadius: radius,
boxShadow: kElevationToShadow[6],
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(style.icon, color: style.foregroundColor),
const SizedBox(width: 12),
Flexible(
child: Text(
entry.message,
style: theme.textTheme.bodyMedium
?.copyWith(color: style.foregroundColor),
),
),
if (entry.actionLabel != null) ...[
const SizedBox(width: 8),
TextButton(
onPressed: () {
entry.onAction?.call();
adaptiveFeedbackHostKey.currentState?.dismiss();
},
child: Text(entry.actionLabel!),
),
],
),
duration: duration,
action: actionLabel != null && onAction != null
? SnackBarAction(label: actionLabel, onPressed: onAction)
: null,
],
),
);
}
}

class _FeedbackStyle {
Expand Down
Loading