603 lines
25 KiB
Dart
603 lines
25 KiB
Dart
import 'dart:ui' show ImageFilter, lerpDouble;
|
||
import 'package:flutter/material.dart';
|
||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||
|
||
import 'package:miler/views/Dashboard/pickups/pickups.dart';
|
||
import 'package:miler/views/Dashboard/home/homepage.dart';
|
||
import 'package:miler/views/Dashboard/activity/activity_page.dart';
|
||
import 'package:miler/views/Dashboard/profile/Profilepage.dart';
|
||
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||
import 'package:miler/views/helpers/constants/narrative.dart';
|
||
import 'package:miler/views/helpers/constants/design_constants.dart';
|
||
import 'package:miler/data/service_profile.dart';
|
||
|
||
class BottomPage extends StatefulWidget {
|
||
final int initialIndex;
|
||
final Widget? overridePage;
|
||
const BottomPage({super.key, this.initialIndex = 0, this.overridePage});
|
||
|
||
/// Cross-tab navigation request.
|
||
static final ValueNotifier<int?> tabRequest = ValueNotifier<int?>(null);
|
||
|
||
/// Switch the shell to [index]. 0 Home · 1 Bookings · 2 Activity · 3 Account.
|
||
static void goToTab(int index) => tabRequest.value = index;
|
||
|
||
/// The tab on screen right now.
|
||
///
|
||
/// Published because the shell keeps every tab **alive**: the pages live in
|
||
/// an `IndexedStack`, which builds all four at launch and only changes which
|
||
/// one is painted. A page's `initState` therefore runs once, before the rider
|
||
/// has done anything — so a tab that loads its data there shows whatever was
|
||
/// true at app start for the rest of the session. Activity hit exactly that:
|
||
/// the rider finished a stop, opened the tab, and read a result fetched
|
||
/// before he had finished anything.
|
||
///
|
||
/// A page that needs to be current listens to this and re-reads when its own
|
||
/// index arrives. Cheaper than rebuilding the tab from scratch, and it keeps
|
||
/// scroll position and state, which is why the IndexedStack is there.
|
||
static final ValueNotifier<int> currentTab = ValueNotifier<int>(0);
|
||
|
||
/// Height of the bar at rest.
|
||
static const double navBarHeight = 66;
|
||
|
||
/// The gap the bar used to float above the screen's edge. Zero now: it is
|
||
/// docked, so there is nothing between it and the bottom of the phone. Kept
|
||
/// as a name because [bottomInset] and several pages read it, and a bar that
|
||
/// changes where it sits should not also change how many numbers describe it.
|
||
static const double navBarGap = 0;
|
||
|
||
/// Height of the bar once the rider has scrolled into a list.
|
||
///
|
||
/// ── Why it shrinks instead of leaving ──
|
||
///
|
||
/// It used to slide off the bottom of the screen entirely and come back when
|
||
/// he reversed. That buys 66pt of list, and costs him the ability to change
|
||
/// tab at all until he scrolls the other way — on a long Activity list, the
|
||
/// only route out is a gesture he has to know about. A control that vanishes
|
||
/// is not a smaller control; it is a missing one.
|
||
///
|
||
/// Compacting keeps every target on screen and still hands back most of the
|
||
/// room: labels go, the icons draw in, and the pill narrows. This is what
|
||
/// Instagram's iOS tab bar does, and it is why that bar never feels in the
|
||
/// way despite never going away.
|
||
static const double navBarHeightCompact = 46;
|
||
|
||
/// Room the bar claims at the foot of a tab page, system inset included.
|
||
///
|
||
/// Always the *resting* height, even while the bar is compact. The bar is a
|
||
/// `Positioned` pill inside the shell's `Stack`, not a `bottomNavigationBar`,
|
||
/// so the Scaffold reserves no room for it and `SafeArea` knows nothing about
|
||
/// it — anything a page pins to the bottom ends up underneath it otherwise.
|
||
///
|
||
/// Deliberately not the live height. Pages used to follow the bar up and down
|
||
/// through a `navVisible` notifier, which meant a pinned banner re-laid itself
|
||
/// out every time the rider changed scroll direction. Reserving the larger of
|
||
/// the two costs 20pt of gap while compact and buys a layout that never moves.
|
||
static double bottomInset(BuildContext context) =>
|
||
MediaQuery.of(context).padding.bottom + navBarGap.h + navBarHeight.h;
|
||
|
||
@override
|
||
State<BottomPage> createState() => _BottomPageState();
|
||
}
|
||
|
||
class _BottomPageState extends State<BottomPage> with TickerProviderStateMixin {
|
||
late int selected;
|
||
late final List<Widget> _pages;
|
||
|
||
late final AnimationController _tabAnim;
|
||
late final Animation<double> _tabCurve;
|
||
|
||
/// Drives the selection pill from the tab it was on to the tab just tapped.
|
||
///
|
||
/// The pill used to be part of each tab — an `AnimatedContainer` whose fill
|
||
/// went from transparent to red — so going Account → Home faded one pill out
|
||
/// on the right while a second faded in on the left. Nothing moved, which is
|
||
/// why it appeared "suddenly": there was never anything to cross Bookings and
|
||
/// Activity with.
|
||
///
|
||
/// There is now exactly one pill, owned by the bar, and this is its position
|
||
/// in tab units (2.0 is dead centre of Activity, 2.5 is between Activity and
|
||
/// Account). It is the single source of truth for the tint on every icon and
|
||
/// label too, so a tab lights up as the pill passes over it rather than
|
||
/// switching colour the instant it is tapped and waiting for the pill to
|
||
/// catch up.
|
||
/// Initialised *here* rather than in `initState`, deliberately.
|
||
///
|
||
/// A `late final` assigned in `initState` is never filled in on a `State` that
|
||
/// already exists, and hot reload reuses the existing `State` — it does not
|
||
/// re-run `initState`. So the reloaded `build` read a controller this instance
|
||
/// had never been given and threw `LateInitializationError: Field '_pillAnim'
|
||
/// has not been initialized`, taking the whole app down to a red screen until
|
||
/// a full restart.
|
||
///
|
||
/// A `late` field initialiser runs on first *access* instead, which happens
|
||
/// during that first post-reload build, so the field is simply there. `this`
|
||
/// is available because the initialiser is lazy — a plain (non-late) field
|
||
/// initialiser could not reach `vsync: this`.
|
||
late final AnimationController _pillAnim = AnimationController(
|
||
vsync: this,
|
||
// Slower than the 280ms the fading pills used. That duration was chosen for
|
||
// a cross-fade in place; a pill that has to physically cross two tabs needs
|
||
// long enough for the eye to follow it, or the travel is technically there
|
||
// and reads as a jump anyway.
|
||
duration: const Duration(milliseconds: 380),
|
||
value: 1.0,
|
||
);
|
||
|
||
/// Symmetric easing: it accelerates away from the old tab and settles into the
|
||
/// new one. `easeOutCubic` — right for something appearing — starts at full
|
||
/// speed, which on a sliding object looks like it was already moving before
|
||
/// the tap.
|
||
late final Animation<double> _pillCurve = CurvedAnimation(
|
||
parent: _pillAnim,
|
||
curve: Curves.easeInOutCubic,
|
||
);
|
||
|
||
late Tween<double> _pillTween = Tween<double>(
|
||
begin: selected.toDouble(),
|
||
end: selected.toDouble(),
|
||
);
|
||
|
||
double get _pillPos => _pillTween.evaluate(_pillCurve);
|
||
|
||
/// 0 at rest, 1 fully compact. Everything the bar changes as it shrinks —
|
||
/// height, corner radius, side inset, icon size, label opacity — is read off
|
||
/// this one value, so the parts cannot drift out of step with each other.
|
||
late final AnimationController _compactAnim = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 260),
|
||
);
|
||
|
||
late final Animation<double> _compact = CurvedAnimation(
|
||
parent: _compactAnim,
|
||
curve: Curves.easeOutCubic,
|
||
reverseCurve: Curves.easeOutCubic,
|
||
);
|
||
|
||
bool _isCompact = false;
|
||
|
||
void _setCompact(bool compact) {
|
||
if (_isCompact == compact) return;
|
||
_isCompact = compact;
|
||
compact ? _compactAnim.forward() : _compactAnim.reverse();
|
||
}
|
||
|
||
double _scrollAccum = 0;
|
||
|
||
static const double _compactAfter = 24;
|
||
static const double _expandAfter = 6;
|
||
|
||
/// ── The work tab is named by the line the rider is on ──
|
||
///
|
||
/// The two rider apps this one replaces called a job by two different names:
|
||
/// **booking** on the parcel side, **delivery** on the meal side. Merging them
|
||
/// did not make one of those wrong — a rider collecting a consignment for the
|
||
/// hub is not delivering it, and a rider handing somebody lunch has not taken
|
||
/// a booking. So the tab reads the noun off the profile instead of picking a
|
||
/// winner, and a rider (who only ever belongs to one line) sees the word he
|
||
/// already uses. See [ServiceProfile.jobNoun].
|
||
///
|
||
/// No longer `const`: the label is resolved per build, not baked in.
|
||
static List<_NavItem> get _navItems => [
|
||
const _NavItem(LucideIcons.house, LucideIcons.house, 'Home'),
|
||
_NavItem(
|
||
LucideIcons.truck,
|
||
LucideIcons.truck,
|
||
ServiceProfile.active.workTabLabel,
|
||
),
|
||
// Was Earnings. A pay figure is a once-or-twice-a-day read and it did not
|
||
// need a permanent quarter of the bar — it opens from Account now. What the
|
||
// rider glances at between stops is what he has already finished, which
|
||
// used to sit on Home clogging the list he decides from.
|
||
const _NavItem(LucideIcons.history, LucideIcons.history, 'Activity'),
|
||
const _NavItem(LucideIcons.user, LucideIcons.user, 'Account'),
|
||
];
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
selected = widget.initialIndex;
|
||
BottomPage.currentTab.value = widget.initialIndex;
|
||
_pages = [
|
||
widget.overridePage ?? const Homepage(),
|
||
const MyPickups(),
|
||
const ActivityPage(),
|
||
const ProfilePage(),
|
||
];
|
||
_tabAnim = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 300),
|
||
value: 1.0,
|
||
);
|
||
_tabCurve = CurvedAnimation(parent: _tabAnim, curve: Curves.easeOutCubic);
|
||
|
||
BottomPage.tabRequest.addListener(_onTabRequested);
|
||
}
|
||
|
||
void _onTabRequested() {
|
||
final requested = BottomPage.tabRequest.value;
|
||
if (requested == null || !mounted) return;
|
||
BottomPage.tabRequest.value = null;
|
||
if (requested >= 0 && requested < _pages.length) _selectTab(requested);
|
||
}
|
||
|
||
void _selectTab(int i) {
|
||
_scrollAccum = 0;
|
||
_setCompact(false);
|
||
if (i == selected) return;
|
||
|
||
HapticFeedback.lightImpact();
|
||
|
||
// Start from where the pill actually *is*, not from the tab it was last
|
||
// headed to. Tapping again mid-slide otherwise teleports it back to the
|
||
// previous tab and re-runs the whole journey.
|
||
_pillTween = Tween<double>(begin: _pillPos, end: i.toDouble());
|
||
_pillAnim.forward(from: 0.0);
|
||
|
||
setState(() => selected = i);
|
||
BottomPage.currentTab.value = i;
|
||
_tabAnim.forward(from: 0.0);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
BottomPage.tabRequest.removeListener(_onTabRequested);
|
||
_tabAnim.dispose();
|
||
_pillAnim.dispose();
|
||
_compactAnim.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
bool _onScroll(ScrollNotification n) {
|
||
if (n.metrics.axis != Axis.vertical) return false;
|
||
|
||
// A page too short to scroll can never compact the bar — otherwise a rider
|
||
// on a short screen ends up with the small version for no reason.
|
||
if (n.metrics.maxScrollExtent <= 0) {
|
||
_scrollAccum = 0;
|
||
_setCompact(false);
|
||
return false;
|
||
}
|
||
|
||
if (n is! ScrollUpdateNotification) return false;
|
||
final delta = n.scrollDelta ?? 0;
|
||
if (delta == 0) return false;
|
||
|
||
_scrollAccum = (_scrollAccum * delta > 0) ? _scrollAccum + delta : delta;
|
||
|
||
if (delta > 0 && !_isCompact && _scrollAccum > _compactAfter) {
|
||
_scrollAccum = 0;
|
||
_setCompact(true);
|
||
} else if (delta < 0 && _isCompact && _scrollAccum < -_expandAfter) {
|
||
_scrollAccum = 0;
|
||
_setCompact(false);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Theme(
|
||
data: Theme.of(context).copyWith(
|
||
textTheme: Theme.of(
|
||
context,
|
||
).textTheme.apply(fontFamily: FontConstants.fontFamily),
|
||
splashColor: Colors.transparent,
|
||
highlightColor: Colors.transparent,
|
||
),
|
||
child: Scaffold(
|
||
backgroundColor: ColorConstants.surface,
|
||
extendBody: true,
|
||
body: NotificationListener<ScrollNotification>(
|
||
onNotification: _onScroll,
|
||
child: Stack(
|
||
children: [
|
||
// Page content
|
||
Positioned.fill(
|
||
child: FadeTransition(
|
||
opacity: _tabCurve,
|
||
child: AnimatedBuilder(
|
||
animation: _tabCurve,
|
||
builder: (_, child) => Transform.translate(
|
||
offset: Offset(0, 8 * (1 - _tabCurve.value)),
|
||
child: child,
|
||
),
|
||
child: IndexedStack(index: selected, children: _pages),
|
||
),
|
||
),
|
||
),
|
||
|
||
// ── Docked, opaque, and full width ──
|
||
//
|
||
// It was a frosted pill floating 10 points off the bottom with a
|
||
// 16-point margin down each side. Two costs, and the second is
|
||
// the one that decided it.
|
||
//
|
||
// **It was not where a nav bar is.** Every other app on the
|
||
// rider's phone puts the tabs on the bottom edge; a pill hovering
|
||
// above it reads as a widget over the page rather than as the
|
||
// frame of the app, and the strip of page visible under it is a
|
||
// gap nothing lives in.
|
||
//
|
||
// **It was glass.** A 20-sigma backdrop blur, permanently on
|
||
// screen, over a scrolling list — the most expensive thing this
|
||
// app drew, on the one surface that is never not drawn.
|
||
//
|
||
// It still compacts rather than leaving: labels go, the icons
|
||
// draw in, the bar shortens. A control that vanishes is not a
|
||
// smaller control, it is a missing one.
|
||
AnimatedBuilder(
|
||
animation: Listenable.merge([_compact, _pillAnim]),
|
||
builder: (context, _) {
|
||
final k = _compact.value;
|
||
final side = 6.w;
|
||
final height = lerpDouble(
|
||
BottomPage.navBarHeight.h,
|
||
BottomPage.navBarHeightCompact.h,
|
||
k,
|
||
)!;
|
||
final radius = lerpDouble(26.r, 20.r, k)!;
|
||
final pos = _pillPos;
|
||
|
||
return Positioned(
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
child: Padding(
|
||
padding: EdgeInsets.only(
|
||
bottom: MediaQuery.of(context).padding.bottom,
|
||
),
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.vertical(
|
||
top: Radius.circular(radius),
|
||
),
|
||
child: DecoratedBox(
|
||
decoration: BoxDecoration(
|
||
color: Narrative.card,
|
||
borderRadius: BorderRadius.vertical(
|
||
top: Radius.circular(radius),
|
||
),
|
||
border: Border(
|
||
top: BorderSide(
|
||
color: Narrative.cardBorder,
|
||
width: 1.0,
|
||
),
|
||
),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withValues(alpha: 0.05),
|
||
blurRadius: 20,
|
||
offset: const Offset(0, -4),
|
||
),
|
||
],
|
||
),
|
||
child: SizedBox(
|
||
height: height,
|
||
child: Padding(
|
||
padding: EdgeInsets.symmetric(
|
||
horizontal: 6.w,
|
||
vertical: lerpDouble(6.h, 4.h, k)!,
|
||
),
|
||
child: LayoutBuilder(
|
||
builder: (context, box) {
|
||
// Every tab gets an equal slot, matching the
|
||
// `Expanded` row below it, so the pill lands
|
||
// exactly under an icon at whole values of
|
||
// `pos`.
|
||
final slot = box.maxWidth / _navItems.length;
|
||
return Stack(
|
||
children: [
|
||
PositionedDirectional(
|
||
start: slot * pos,
|
||
top: 0,
|
||
bottom: 0,
|
||
width: slot,
|
||
child: Container(
|
||
margin: EdgeInsets.symmetric(
|
||
horizontal: 3.w,
|
||
),
|
||
decoration: BoxDecoration(
|
||
// Brand, to agree with the glyph
|
||
// it sits behind — see the tint in
|
||
// [_NavBarItem].
|
||
color: ColorConstants.primaryWash,
|
||
borderRadius: BorderRadius.circular(
|
||
lerpDouble(34.r, 20.r, k)!,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
Row(
|
||
children: List.generate(
|
||
_navItems.length,
|
||
(i) {
|
||
// How lit this tab is: 1 with the
|
||
// pill on it, 0 once the pill is a
|
||
// whole tab away. The tabs being
|
||
// crossed therefore warm up and cool
|
||
// down as it passes, which is what
|
||
// sells the movement.
|
||
final t = (1.0 - (pos - i).abs())
|
||
.clamp(0.0, 1.0);
|
||
return Expanded(
|
||
child: _NavBarItem(
|
||
item: _navItems[i],
|
||
t: t,
|
||
compact: k,
|
||
onTap: () => _selectTab(i),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _NavItem {
|
||
final IconData activeIcon;
|
||
final IconData inactiveIcon;
|
||
final String label;
|
||
const _NavItem(this.activeIcon, this.inactiveIcon, this.label);
|
||
}
|
||
|
||
class _NavBarItem extends StatelessWidget {
|
||
final _NavItem item;
|
||
|
||
/// How lit this tab is, 0 → 1, driven by where the selection pill currently
|
||
/// is rather than by which tab is selected.
|
||
///
|
||
/// This is the whole point of the redesign. With a boolean the icon flipped
|
||
/// red the instant it was tapped while the pill was still two tabs away —
|
||
/// destination and vehicle arriving at different times. Tying the tint to the
|
||
/// pill's distance instead means Bookings and Activity glow briefly as it
|
||
/// crosses them and fade again behind it.
|
||
final double t;
|
||
|
||
/// 0 at rest, 1 fully compact. The label collapses and the icon draws in as
|
||
/// this rises — see [BottomPage.navBarHeightCompact].
|
||
final double compact;
|
||
|
||
final VoidCallback onTap;
|
||
|
||
const _NavBarItem({
|
||
required this.item,
|
||
required this.t,
|
||
required this.compact,
|
||
required this.onTap,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
// ── The selected tab wears the brand ──
|
||
//
|
||
// This was [ColorConstants.emphasis], the neutral slate, on the rule that
|
||
// colour marks meaning and neutral marks state. The brand mark is not on
|
||
// any tab now that Home carries the rider's own avatar, so the nav bar is
|
||
// where Doormile's red belongs: it is the one piece of chrome on screen at
|
||
// all times, and the selected tab is the one thing on it worth colouring.
|
||
//
|
||
// #960019 reads at 9.4:1 on the bar's near-white frost, so it is legible
|
||
// outdoors — which is the only reason the neutral was ever safer.
|
||
final Color activeColor = ColorConstants.primary;
|
||
final Color inactiveColor = ColorConstants.onSurfaceVariant;
|
||
|
||
// Eased rather than linear: a straight ramp makes every tab the pill passes
|
||
// over look half-selected, so the bar reads as having no clear selection
|
||
// mid-slide. Cubed keeps the glow tight around the pill.
|
||
final lit = t * t * t;
|
||
final tint = Color.lerp(inactiveColor, activeColor, lit)!;
|
||
|
||
// The app's primary navigation was four unlabelled `InkWell`s: a screen
|
||
// reader announced "Home", "Bookings", "Activity", "Account" as loose text
|
||
// with no role and no indication of which one the rider was on. `selected`
|
||
// is the part that matters — without it he cannot tell where he is.
|
||
return Semantics(
|
||
button: true,
|
||
selected: lit > 0.5,
|
||
label: item.label,
|
||
excludeSemantics: true,
|
||
child: InkWell(
|
||
onTap: onTap,
|
||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||
splashColor: Colors.transparent,
|
||
highlightColor: Colors.transparent,
|
||
// Transparent, not tinted: the pill behind the row is the fill now.
|
||
child: Padding(
|
||
padding: EdgeInsets.symmetric(vertical: 4.h, horizontal: 4.w),
|
||
child: Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Transform.scale(
|
||
scale: (1.0 + 0.08 * lit) * (1 - 0.06 * compact),
|
||
// Filled and outline glyph stacked and cross-faded on the same
|
||
// value, so the icon fills in as the pill arrives. An
|
||
// `AnimatedSwitcher` keyed off a bool ran on its own 220ms clock
|
||
// and finished well before the pill did.
|
||
child: Stack(
|
||
alignment: Alignment.center,
|
||
children: [
|
||
Opacity(
|
||
opacity: 1 - lit,
|
||
child: Icon(
|
||
item.inactiveIcon,
|
||
color: tint,
|
||
size: 21.sp,
|
||
),
|
||
),
|
||
Opacity(
|
||
opacity: lit,
|
||
child: Icon(item.activeIcon, color: tint, size: 21.sp),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
// ── The label collapses, it does not just fade ──
|
||
//
|
||
// Fading alone would leave its height behind, so the bar could
|
||
// not actually get shorter — it would shrink around a blank
|
||
// line. `Align(heightFactor:)` inside a `ClipRect` hands the
|
||
// room back as the text goes.
|
||
//
|
||
// The opacity runs ahead of the height (×1.8) so the label is
|
||
// gone before the line has finished closing, rather than being
|
||
// squashed on the way out.
|
||
ClipRect(
|
||
child: Align(
|
||
alignment: Alignment.topCenter,
|
||
heightFactor: (1 - compact).clamp(0.0, 1.0),
|
||
child: Opacity(
|
||
opacity: (1 - compact * 1.8).clamp(0.0, 1.0),
|
||
child: Padding(
|
||
padding: EdgeInsets.only(top: 2.h),
|
||
child: Text(
|
||
item.label,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(
|
||
color: tint,
|
||
fontSize: 10.5.sp,
|
||
fontWeight: FontWeight.lerp(
|
||
FontWeight.w600,
|
||
FontWeight.w700,
|
||
lit,
|
||
),
|
||
fontFamily: FontConstants.fontFamily,
|
||
letterSpacing: -0.1,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|