diff --git a/packages/desktop-client/src/ResponsiveProvider.tsx b/packages/desktop-client/src/ResponsiveProvider.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d31ec6e3e3d83815061eab067d8f72a3145f739a --- /dev/null +++ b/packages/desktop-client/src/ResponsiveProvider.tsx @@ -0,0 +1,55 @@ +import { type ReactNode, createContext, useContext } from 'react'; + +import { useViewportSize } from '@react-aria/utils'; + +import { breakpoints } from './tokens'; + +type TResponsiveContext = { + atLeastMediumWidth: boolean; + isNarrowWidth: boolean; + isSmallWidth: boolean; + isMediumWidth: boolean; + isWideWidth: boolean; + height: number; + width: number; +}; + +const ResponsiveContext = createContext<TResponsiveContext>(null); + +export function ResponsiveProvider(props: { children: ReactNode }) { + /* + * Ensure we render on every viewport size change, + * even though we're interested in document.documentElement.client<Width|Height> + * clientWidth/Height are the document size, do not change on pinch-zoom, + * and are what our `min-width` media queries are reading + * Viewport size changes on pinch-zoom, which may be useful later when dealing with on-screen keyboards + */ + useViewportSize(); + + const height = document.documentElement.clientHeight; + const width = document.documentElement.clientWidth; + + // Possible view modes: narrow, small, medium, wide + // To check if we're at least small width, check !isNarrowWidth + const viewportInfo = { + // atLeastMediumWidth is provided to avoid checking (isMediumWidth || isWideWidth) + atLeastMediumWidth: width >= breakpoints.medium, + isNarrowWidth: width < breakpoints.small, + isSmallWidth: width >= breakpoints.small && width < breakpoints.medium, + isMediumWidth: width >= breakpoints.medium && width < breakpoints.wide, + // No atLeastWideWidth because that's identical to isWideWidth + isWideWidth: width >= breakpoints.wide, + height, + width, + }; + + return ( + <ResponsiveContext.Provider value={viewportInfo}> + {props.children} + </ResponsiveContext.Provider> + ); +} + +export function useResponsive() { + return useContext(ResponsiveContext); +} diff --git a/packages/desktop-client/src/components/App.js b/packages/desktop-client/src/components/App.js index 3fbc61eb67de0afe19037a1e765abf57ce0b193f..4a314e4f970eb3f84eef91baf7a77c51997494aa 100644 --- a/packages/desktop-client/src/components/App.js +++ b/packages/desktop-client/src/components/App.js @@ -10,6 +10,7 @@ import { } from 'loot-core/src/platform/client/fetch'; import installPolyfills from '../polyfills'; +import { ResponsiveProvider } from '../ResponsiveProvider'; import { styles, hasHiddenScrollbars } from '../style'; import AppBackground from './AppBackground'; @@ -90,42 +91,44 @@ class App extends Component { const { fatalError, initializing, hiddenScrollbars } = this.state; return ( - <div - key={hiddenScrollbars ? 'hidden-scrollbars' : 'scrollbars'} - {...css([ - { - height: '100%', - backgroundColor: '#E8ECF0', - overflow: 'hidden', - }, - styles.lightScrollbar, - ])} - > - {fatalError ? ( - <> - <AppBackground /> - <FatalError error={fatalError} buttonText="Restart app" /> - </> - ) : initializing ? ( - <AppBackground - initializing={initializing} - loadingText={loadingText} - /> - ) : budgetId ? ( - <FinancesApp /> - ) : ( - <> + <ResponsiveProvider> + <div + key={hiddenScrollbars ? 'hidden-scrollbars' : 'scrollbars'} + {...css([ + { + height: '100%', + backgroundColor: '#E8ECF0', + overflow: 'hidden', + }, + styles.lightScrollbar, + ])} + > + {fatalError ? ( + <> + <AppBackground /> + <FatalError error={fatalError} buttonText="Restart app" /> + </> + ) : initializing ? ( <AppBackground initializing={initializing} loadingText={loadingText} /> - <ManagementApp isLoading={loadingText != null} /> - </> - )} - - <UpdateNotification /> - <MobileWebMessage /> - </div> + ) : budgetId ? ( + <FinancesApp /> + ) : ( + <> + <AppBackground + initializing={initializing} + loadingText={loadingText} + /> + <ManagementApp isLoading={loadingText != null} /> + </> + )} + + <UpdateNotification /> + <MobileWebMessage /> + </div> + </ResponsiveProvider> ); } } diff --git a/packages/desktop-client/src/components/FinancesApp.js b/packages/desktop-client/src/components/FinancesApp.js index 61e4cfa12ada23b911d65d5a905087c264f2b54a..6ec499df2470855b277bc26c311fc9a3b5cb09e8 100644 --- a/packages/desktop-client/src/components/FinancesApp.js +++ b/packages/desktop-client/src/components/FinancesApp.js @@ -1,4 +1,4 @@ -import React, { Component, useMemo } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { DndProvider } from 'react-dnd'; import Backend from 'react-dnd-html5-backend'; import { connect } from 'react-redux'; @@ -26,19 +26,19 @@ import * as undo from 'loot-core/src/platform/client/undo'; import Cog from '../icons/v1/Cog'; import PiggyBank from '../icons/v1/PiggyBank'; import Wallet from '../icons/v1/Wallet'; +import { useResponsive } from '../ResponsiveProvider'; import { colors, styles } from '../style'; -import { isMobile } from '../util'; import { getLocationState, makeLocationState } from '../util/location-state'; import { getIsOutdated, getLatestVersion } from '../util/versions'; import Account from './accounts/Account'; -import { default as MobileAccount } from './accounts/MobileAccount'; -import { default as MobileAccounts } from './accounts/MobileAccounts'; +import MobileAccount from './accounts/MobileAccount'; +import MobileAccounts from './accounts/MobileAccounts'; import { ActiveLocationProvider } from './ActiveLocation'; import BankSyncStatus from './BankSyncStatus'; import Budget from './budget'; import { BudgetMonthCountProvider } from './budget/BudgetMonthCountContext'; -import { default as MobileBudget } from './budget/MobileBudget'; +import MobileBudget from './budget/MobileBudget'; import { View } from './common'; import FloatableSidebar, { SidebarProvider } from './FloatableSidebar'; import GlobalKeys from './GlobalKeys'; @@ -57,8 +57,14 @@ import PostsOfflineNotification from './schedules/PostsOfflineNotification'; import Settings from './settings'; import Titlebar, { TitlebarProvider } from './Titlebar'; -function PageRoute({ path, component: Component }) { - return ( +function PageRoute({ + path, + component: Component, + redirectTo = '/budget', + worksInNarrow = true, +}) { + const { isNarrowWidth } = useResponsive(); + return worksInNarrow || !isNarrowWidth ? ( <Route path={path} children={props => { @@ -74,52 +80,100 @@ function PageRoute({ path, component: Component }) { ); }} /> + ) : ( + <Redirect to={redirectTo} /> ); } -function Routes({ isMobile, location }) { +function NonPageRoute({ + redirectTo = '/budget', + worksInNarrow = true, + ...routeProps +}) { + const { isNarrowWidth } = useResponsive(); + + return worksInNarrow || !isNarrowWidth ? ( + <Route {...routeProps} /> + ) : ( + <Redirect to={redirectTo} /> + ); +} + +function Routes({ location }) { + const { isNarrowWidth } = useResponsive(); return ( <Switch location={location}> - <Route path="/" exact render={() => <Redirect to="/budget" />} /> + <Redirect from="/" exact to="/budget" /> - <PageRoute path="/reports" component={Reports} /> - <PageRoute path="/budget" component={isMobile ? MobileBudget : Budget} /> + <PageRoute path="/reports" component={Reports} worksInNarrow={false} /> - <Route path="/schedules" exact component={Schedules} /> - <Route path="/schedule/edit" exact component={EditSchedule} /> - <Route path="/schedule/edit/:id" component={EditSchedule} /> - <Route path="/schedule/link" component={LinkSchedule} /> - <Route path="/schedule/discover" component={DiscoverSchedules} /> - <Route + <PageRoute + path="/budget" + component={isNarrowWidth ? MobileBudget : Budget} + /> + + <NonPageRoute + path="/schedules" + exact + component={Schedules} + worksInNarrow={false} + /> + <NonPageRoute + path="/schedule/edit" + exact + component={EditSchedule} + worksInNarrow={false} + /> + <NonPageRoute + path="/schedule/edit/:id" + component={EditSchedule} + worksInNarrow={false} + /> + <NonPageRoute + path="/schedule/link" + component={LinkSchedule} + worksInNarrow={false} + /> + <NonPageRoute + path="/schedule/discover" + component={DiscoverSchedules} + worksInNarrow={false} + /> + <NonPageRoute path="/schedule/posts-offline-notification" component={PostsOfflineNotification} /> - <Route path="/payees" exact component={ManagePayeesPage} /> - <Route path="/rules" exact component={ManageRulesPage} /> - <Route path="/settings" component={Settings} /> - <Route path="/nordigen/link" exact component={NordigenLink} /> + <NonPageRoute path="/payees" exact component={ManagePayeesPage} /> + <NonPageRoute path="/rules" exact component={ManageRulesPage} /> + <NonPageRoute path="/settings" component={Settings} /> + <NonPageRoute + path="/nordigen/link" + exact + component={NordigenLink} + worksInNarrow={false} + /> - <Route + <NonPageRoute path="/accounts/:id" exact children={props => { - const AcctCmp = isMobile ? MobileAccount : Account; + const AcctCmp = isNarrowWidth ? MobileAccount : Account; return ( props.match && <AcctCmp key={props.match.params.id} {...props} /> ); }} /> - <Route + <NonPageRoute path="/accounts" exact - component={isMobile ? MobileAccounts : Account} + component={isNarrowWidth ? MobileAccounts : Account} /> </Switch> ); } -function StackedRoutes({ isMobile }) { +function StackedRoutes() { let location = useLocation(); let locationPtr = getLocationState(location, 'locationPtr'); @@ -134,14 +188,14 @@ function StackedRoutes({ isMobile }) { return ( <ActiveLocationProvider location={locations[locations.length - 1]}> - <Routes location={base} isMobile={isMobile} /> + <Routes location={base} /> {stack.map((location, idx) => ( <PageTypeProvider key={location.key} type="modal" current={idx === stack.length - 1} > - <Routes location={location} isMobile={isMobile} /> + <Routes location={location} /> </PageTypeProvider> ))} </ActiveLocationProvider> @@ -172,6 +226,7 @@ function NavTab({ icon: TabIcon, name, path }) { } function MobileNavTabs() { + const { isNarrowWidth } = useResponsive(); return ( <div style={{ @@ -179,71 +234,60 @@ function MobileNavTabs() { borderTop: `1px solid ${colors.n10}`, bottom: 0, ...styles.shadow, - display: 'flex', + display: isNarrowWidth ? 'flex' : 'none', height: '80px', justifyContent: 'space-around', paddingTop: 10, width: '100%', }} > - <NavTab name="Budget" path="/budget" icon={Wallet} isActive={false} /> - <NavTab - name="Accounts" - path="/accounts" - icon={PiggyBank} - isActive={false} - /> - <NavTab name="Settings" path="/settings" icon={Cog} isActive={false} /> + <NavTab name="Budget" path="/budget" icon={Wallet} /> + <NavTab name="Accounts" path="/accounts" icon={PiggyBank} /> + <NavTab name="Settings" path="/settings" icon={Cog} /> </div> ); } -class FinancesApp extends Component { - constructor(props) { - super(props); - this.state = { isMobile: isMobile() }; - this.history = createBrowserHistory(); +function FinancesApp(props) { + const [patchedHistory] = useState(() => createBrowserHistory()); - let oldPush = this.history.push; - this.history.push = (to, state) => { + useEffect(() => { + let oldPush = patchedHistory.push; + patchedHistory.push = (to, state) => { let newState = makeLocationState(to.state || state); if (typeof to === 'object') { - return oldPush.call(this.history, { ...to, state: newState }); + return oldPush.call(patchedHistory, { ...to, state: newState }); } else { - return oldPush.call(this.history, to, newState); + return oldPush.call(patchedHistory, to, newState); } }; // I'm not sure if this is the best approach but we need this to // globally. We could instead move various workflows inside global // React components, but that's for another day. - window.__history = this.history; + window.__history = patchedHistory; undo.setUndoState('url', window.location.href); - this.cleanup = this.history.listen(location => { + const cleanup = patchedHistory.listen(location => { undo.setUndoState('url', window.location.href); }); - this.handleWindowResize = this.handleWindowResize.bind(this); - } - - handleWindowResize() { - this.setState({ isMobile: isMobile() }); - } + return cleanup; + }, []); - componentDidMount() { + useEffect(() => { // TODO: quick hack fix for showing the demo - if (this.history.location.pathname === '/subscribe') { - this.history.push('/'); + if (patchedHistory.location.pathname === '/subscribe') { + patchedHistory.push('/'); } // Get the accounts and check if any exist. If there are no // accounts, we want to redirect the user to the All Accounts // screen which will prompt them to add an account - this.props.getAccounts().then(accounts => { + props.getAccounts().then(accounts => { if (accounts.length === 0) { - this.history.push('/accounts'); + patchedHistory.push('/accounts'); } }); @@ -253,96 +297,81 @@ class FinancesApp extends Component { // Wait a little bit to make sure the sync button will get the // sync start event. This can be improved later. setTimeout(async () => { - await this.props.sync(); + await props.sync(); // Check for upgrade notifications. We do this after syncing // because these states are synced across devices, so they will // only see it once for this file checkForUpgradeNotifications( - this.props.addNotification, - this.props.resetSync, - this.history, + props.addNotification, + props.resetSync, + patchedHistory, ); - }, 100); - setTimeout(async () => { - await this.props.sync(); await checkForUpdateNotification( - this.props.addNotification, + props.addNotification, getIsOutdated, getLatestVersion, - this.props.loadPrefs, - this.props.savePrefs, + props.loadPrefs, + props.savePrefs, ); }, 100); + }, []); - window.addEventListener('resize', this.handleWindowResize); - } - - componentWillUnmount() { - this.cleanup(); - window.removeEventListener('resize', this.handleWindowResize); - } - - render() { - return ( - <Router history={this.history}> - <CompatRouter> - <View style={{ height: '100%', backgroundColor: colors.n10 }}> - <GlobalKeys /> - - <View style={{ flexDirection: 'row', flex: 1 }}> - {!this.state.isMobile && <FloatableSidebar />} - + return ( + <Router history={patchedHistory}> + <CompatRouter> + <View style={{ height: '100%', backgroundColor: colors.n10 }}> + <GlobalKeys /> + + <View style={{ flexDirection: 'row', flex: 1 }}> + <FloatableSidebar /> + + <div + style={{ + flex: 1, + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', + position: 'relative', + width: '100%', + }} + > + <Titlebar + style={{ + WebkitAppRegion: 'drag', + position: 'absolute', + top: 0, + left: 0, + right: 0, + zIndex: 1000, + }} + /> <div style={{ flex: 1, display: 'flex', - flexDirection: 'column', - overflow: 'hidden', + overflow: 'auto', position: 'relative', - width: '100%', }} > - {!this.state.isMobile && ( - <Titlebar - style={{ - WebkitAppRegion: 'drag', - position: 'absolute', - top: 0, - left: 0, - right: 0, - zIndex: 1000, - }} - /> - )} - <div - style={{ - flex: 1, - display: 'flex', - overflow: 'auto', - position: 'relative', - }} - > - <Notifications /> - <BankSyncStatus /> - <StackedRoutes isMobile={this.state.isMobile} /> - <Modals history={this.history} /> - </div> - {this.state.isMobile && ( - <Switch> - <Route path="/budget" component={MobileNavTabs} /> - <Route path="/accounts" component={MobileNavTabs} /> - <Route path="/settings" component={MobileNavTabs} /> - </Switch> - )} + <Notifications /> + <BankSyncStatus /> + <StackedRoutes /> + <Modals history={patchedHistory} /> </div> - </View> + + <Switch> + <Route path="/budget" component={MobileNavTabs} /> + <Route path="/accounts" component={MobileNavTabs} /> + <Route path="/settings" component={MobileNavTabs} /> + </Switch> + </div> </View> - </CompatRouter> - </Router> - ); - } + </View> + </CompatRouter> + </Router> + ); } function FinancesAppWithContext(props) { diff --git a/packages/desktop-client/src/components/FloatableSidebar.js b/packages/desktop-client/src/components/FloatableSidebar.js index 03f685586ef9e7f19a176780083cd2187fd4dcb7..444ee866e59a73a46a4a94bea0984feec93a04aa 100644 --- a/packages/desktop-client/src/components/FloatableSidebar.js +++ b/packages/desktop-client/src/components/FloatableSidebar.js @@ -1,11 +1,11 @@ import React, { createContext, useState, useContext, useMemo } from 'react'; -import { connect } from 'react-redux'; +import { connect, useSelector } from 'react-redux'; import { withRouter } from 'react-router-dom'; -import { useViewportSize } from '@react-aria/utils'; - import * as actions from 'loot-core/src/client/actions'; +import { useResponsive } from '../ResponsiveProvider'; + import { View } from './common'; import { SIDEBAR_WIDTH } from './sidebar'; import SidebarWithData from './SidebarWithData'; @@ -13,32 +13,40 @@ import SidebarWithData from './SidebarWithData'; const SidebarContext = createContext(null); export function SidebarProvider({ children }) { + let floatingSidebar = useSelector( + state => state.prefs.global.floatingSidebar, + ); let [hidden, setHidden] = useState(true); + let { width } = useResponsive(); + let alwaysFloats = width < 668; + let floating = floatingSidebar || alwaysFloats; + return ( - <SidebarContext.Provider value={[hidden, setHidden]}> + <SidebarContext.Provider + value={{ hidden, setHidden, floating, alwaysFloats }} + > {children} </SidebarContext.Provider> ); } export function useSidebar() { - useViewportSize(); // Force re-render on window resize - let windowWidth = document.documentElement.clientWidth; - let alwaysFloats = windowWidth < 668; + let { hidden, setHidden, floating, alwaysFloats } = + useContext(SidebarContext); - let [hidden, setHidden] = useContext(SidebarContext); return useMemo( - () => ({ hidden, setHidden, alwaysFloats }), - [hidden, setHidden, alwaysFloats], + () => ({ hidden, setHidden, floating, alwaysFloats }), + [hidden, setHidden, floating, alwaysFloats], ); } function Sidebar({ floatingSidebar }) { let sidebar = useSidebar(); + let { isNarrowWidth } = useResponsive(); let sidebarShouldFloat = floatingSidebar || sidebar.alwaysFloats; - return ( + return isNarrowWidth ? null : ( <View onMouseOver={ sidebarShouldFloat diff --git a/packages/desktop-client/src/components/MobileWebMessage.js b/packages/desktop-client/src/components/MobileWebMessage.js index e74a4637d2de311323007bd55302b2fd375721c4..71296e4df98d803d3f2e2f8ee114c5a2277aeab6 100644 --- a/packages/desktop-client/src/components/MobileWebMessage.js +++ b/packages/desktop-client/src/components/MobileWebMessage.js @@ -3,8 +3,8 @@ import { useDispatch, useSelector } from 'react-redux'; import { savePrefs } from 'loot-core/src/client/actions'; +import { useResponsive } from '../ResponsiveProvider'; import { colors, styles } from '../style'; -import { isMobile } from '../util'; import { View, Text, Button } from './common'; import { Checkbox } from './forms'; @@ -16,8 +16,10 @@ export default function MobileWebMessage() { return (state.prefs.local && state.prefs.local.hideMobileMessage) || true; }); + const { isNarrowWidth } = useResponsive(); + let [show, setShow] = useState( - isMobile() && + isNarrowWidth && !hideMobileMessagePref && !document.cookie.match(/hideMobileMessage=true/), ); diff --git a/packages/desktop-client/src/components/Page.js b/packages/desktop-client/src/components/Page.js index 7c78170629be3698c89288f505cd1d0f854dcfce..ccacf653ba1be94ed5c1abaaeff6d46e84c6cfb1 100644 --- a/packages/desktop-client/src/components/Page.js +++ b/packages/desktop-client/src/components/Page.js @@ -1,15 +1,13 @@ import React, { createContext, useContext } from 'react'; import { useHistory } from 'react-router-dom'; +import { useResponsive } from '../ResponsiveProvider'; import { colors, styles } from '../style'; -import { isMobile } from '../util'; import { Modal, View, Text } from './common'; let PageTypeContext = createContext({ type: 'page' }); -const HORIZONTAL_PADDING = isMobile() ? 10 : 20; - export function PageTypeProvider({ type, current, children }) { return ( <PageTypeContext.Provider value={{ type, current }}> @@ -23,7 +21,9 @@ export function usePageType() { } function PageTitle({ name, style }) { - if (isMobile()) { + const { isNarrowWidth } = useResponsive(); + + if (isNarrowWidth) { return ( <View style={[ @@ -53,8 +53,6 @@ function PageTitle({ name, style }) { { fontSize: 25, fontWeight: 500, - paddingLeft: HORIZONTAL_PADDING, - paddingRight: HORIZONTAL_PADDING, marginBottom: 15, }, style, @@ -68,6 +66,8 @@ function PageTitle({ name, style }) { export function Page({ title, modalSize, children, titleStyle }) { let { type, current } = usePageType(); let history = useHistory(); + let { isNarrowWidth } = useResponsive(); + let HORIZONTAL_PADDING = isNarrowWidth ? 10 : 20; if (type === 'modal') { let size = modalSize; @@ -89,11 +89,17 @@ export function Page({ title, modalSize, children, titleStyle }) { } return ( - <View style={isMobile() ? undefined : styles.page}> - <PageTitle name={title} style={titleStyle} /> + <View style={isNarrowWidth ? undefined : styles.page}> + <PageTitle + name={title} + style={{ + ...titleStyle, + paddingInline: HORIZONTAL_PADDING, + }} + /> <View style={ - isMobile() + isNarrowWidth ? { overflowY: 'auto', padding: HORIZONTAL_PADDING } : { paddingLeft: HORIZONTAL_PADDING, diff --git a/packages/desktop-client/src/components/Titlebar.js b/packages/desktop-client/src/components/Titlebar.js index 2aff10480b825c5b6a484177cd5f836979983464..1b80258add2162171521297b0408b5e7f6ff2065 100644 --- a/packages/desktop-client/src/components/Titlebar.js +++ b/packages/desktop-client/src/components/Titlebar.js @@ -19,6 +19,7 @@ import useFeatureFlag from '../hooks/useFeatureFlag'; import ArrowLeft from '../icons/v1/ArrowLeft'; import AlertTriangle from '../icons/v2/AlertTriangle'; import NavigationMenu from '../icons/v2/NavigationMenu'; +import { useResponsive } from '../ResponsiveProvider'; import { colors } from '../style'; import tokens from '../tokens'; @@ -136,7 +137,7 @@ export function SyncButton({ localPrefs, style, onSync }) { ? colors.n9 : null, }, - media(`(min-width: ${tokens.breakpoint_medium})`, { + media(`(min-width: ${tokens.breakpoint_small})`, { color: syncState === 'error' ? colors.r4 @@ -269,9 +270,10 @@ function Titlebar({ sync, }) { let sidebar = useSidebar(); + let { isNarrowWidth } = useResponsive(); const serverURL = useServerURL(); - return ( + return isNarrowWidth ? null : ( <View style={[ { diff --git a/packages/desktop-client/src/components/common/Modal.tsx b/packages/desktop-client/src/components/common/Modal.tsx index bdfcc9f4c6005dae11a2cf03f3f7ce384281d469..8c99af242b60401c2861a4782f125627b27c8d23 100644 --- a/packages/desktop-client/src/components/common/Modal.tsx +++ b/packages/desktop-client/src/components/common/Modal.tsx @@ -126,8 +126,8 @@ const Modal = ({ borderRadius: 4, backgroundColor: 'white', opacity: isHidden ? 0 : 1, - [`@media (min-width: ${tokens.breakpoint_narrow})`]: { - minWidth: tokens.breakpoint_narrow, + [`@media (min-width: ${tokens.breakpoint_small})`]: { + minWidth: tokens.breakpoint_small, }, }, styles.shadowLarge, diff --git a/packages/desktop-client/src/components/manager/BudgetList.js b/packages/desktop-client/src/components/manager/BudgetList.js index 8957425faee6db062d071d318986c1af8b2b5c9c..4e6a9f9988b9df27758dacc300f6d4d695c71365 100644 --- a/packages/desktop-client/src/components/manager/BudgetList.js +++ b/packages/desktop-client/src/components/manager/BudgetList.js @@ -197,7 +197,7 @@ function BudgetTable({ files, onSelect, onDelete }) { <View style={{ flexGrow: 1, - [`@media (min-width: ${tokens.breakpoint_narrow})`]: { + [`@media (min-width: ${tokens.breakpoint_small})`]: { flexGrow: 0, maxHeight: 310, }, @@ -261,12 +261,12 @@ function BudgetList({ style={{ flex: 1, justifyContent: 'center', - minWidth: tokens.breakpoint_narrow, - [`@media (max-width: ${tokens.breakpoint_narrow})`]: { - width: '100vw', - minWidth: '100vw', - marginInline: -20, - marginTop: 20, + marginInline: -20, + marginTop: 20, + width: '100vw', + [`@media (min-width: ${tokens.breakpoint_small})`]: { + maxWidth: tokens.breakpoint_small, + width: '100%', }, }} > diff --git a/packages/desktop-client/src/components/manager/ManagementApp.js b/packages/desktop-client/src/components/manager/ManagementApp.js index 682fe0a97f2b7be54381e3f55ad504184d66aafe..d5724e0da42ee41697ac89584e558a1fe5956031 100644 --- a/packages/desktop-client/src/components/manager/ManagementApp.js +++ b/packages/desktop-client/src/components/manager/ManagementApp.js @@ -33,7 +33,7 @@ function Version() { ':hover': { color: colors.n2 }, margin: 15, marginLeft: 17, - [`@media (min-width: ${tokens.breakpoint_medium})`]: { + [`@media (min-width: ${tokens.breakpoint_small})`]: { position: 'absolute', bottom: 0, right: 0, diff --git a/packages/desktop-client/src/components/settings/Format.js b/packages/desktop-client/src/components/settings/Format.js index ce27e3d6239ebd39e043efdefc1019d9ae33e1f8..50e0b8ad71d807d01c4663e2ef3335045804ddeb 100644 --- a/packages/desktop-client/src/components/settings/Format.js +++ b/packages/desktop-client/src/components/settings/Format.js @@ -4,6 +4,7 @@ import { numberFormats } from 'loot-core/src/shared/util'; import tokens from '../../tokens'; import { Button, CustomSelect, Text, View } from '../common'; +import { useSidebar } from '../FloatableSidebar'; import { Checkbox } from '../forms'; import { Setting } from './UI'; @@ -33,11 +34,9 @@ function Column({ title, children }) { <View style={{ alignItems: 'flex-start', - gap: '0.5em', flexGrow: 1, - [`@media (max-width: ${tokens.breakpoint_xs})`]: { - width: '100%', - }, + gap: '0.5em', + width: '100%', }} > <Text style={{ fontWeight: 500 }}>{title}</Text> @@ -64,6 +63,7 @@ export default function FormatSettings({ prefs, savePrefs }) { savePrefs({ hideFraction }); } + let sidebar = useSidebar(); let firstDayOfWeekIdx = prefs.firstDayOfWeekIdx || '0'; // Sunday let dateFormat = prefs.dateFormat || 'MM/dd/yyyy'; let numberFormat = prefs.numberFormat || 'comma-dot'; @@ -73,11 +73,15 @@ export default function FormatSettings({ prefs, savePrefs }) { primaryAction={ <View style={{ - flexDirection: 'row', + flexDirection: 'column', gap: '1em', width: '100%', - [`@media (max-width: ${tokens.breakpoint_xs})`]: { - flexDirection: 'column', + [`@media (min-width: ${ + sidebar.floating + ? tokens.breakpoint_small + : tokens.breakpoint_medium + })`]: { + flexDirection: 'row', }, }} > diff --git a/packages/desktop-client/src/components/settings/UI.tsx b/packages/desktop-client/src/components/settings/UI.tsx index d64897fd9bd6acf58c38d6af9861820d97704329..40d39221dbb01798cbf70e38dfe9f96027fc5cd0 100644 --- a/packages/desktop-client/src/components/settings/UI.tsx +++ b/packages/desktop-client/src/components/settings/UI.tsx @@ -63,7 +63,7 @@ export const AdvancedToggle = ({ children }: AdvancedToggleProps) => { marginBottom: 25, width: '100%', }, - media(`(min-width: ${tokens.breakpoint_medium})`, { + media(`(min-width: ${tokens.breakpoint_small})`, { width: 'auto', }), ]} diff --git a/packages/desktop-client/src/components/settings/index.js b/packages/desktop-client/src/components/settings/index.js index 2c1d0502f28fa873759942aef4621736086506ed..c56f0775e4f6ccffd8a4632ff598e4eb0e15ae10 100644 --- a/packages/desktop-client/src/components/settings/index.js +++ b/packages/desktop-client/src/components/settings/index.js @@ -8,9 +8,9 @@ import * as Platform from 'loot-core/src/client/platform'; import { listen } from 'loot-core/src/platform/client/fetch'; import useLatestVersion, { useIsOutdated } from '../../hooks/useLatestVersion'; +import { useResponsive } from '../../ResponsiveProvider'; import { colors } from '../../style'; import tokens from '../../tokens'; -import { isMobile } from '../../util'; import { withThemeColor } from '../../util/withThemeColor'; import { View, Text, Button, Input } from '../common'; import { FormField, FormLabel } from '../forms'; @@ -40,7 +40,7 @@ function About() { <View style={[ { flexDirection: 'column', gap: 10 }, - media(`(min-width: ${tokens.breakpoint_medium})`, { + media(`(min-width: ${tokens.breakpoint_small})`, { display: 'grid', gridTemplateRows: '1fr 1fr', gridTemplateColumns: '50% 50%', @@ -122,16 +122,19 @@ function Settings({ return () => unlisten(); }, [loadPrefs]); + const { isNarrowWidth } = useResponsive(); + return ( <View style={{ - marginInline: globalPrefs.floatingSidebar && !isMobile() ? 'auto' : 0, + marginInline: + globalPrefs.floatingSidebar && !isNarrowWidth ? 'auto' : 0, }} > <Page title="Settings" titleStyle={ - isMobile() + isNarrowWidth ? { backgroundColor: colors.n11, color: colors.n1, @@ -140,7 +143,7 @@ function Settings({ } > <View style={{ flexShrink: 0, gap: 30 }}> - {isMobile() && ( + {isNarrowWidth && ( <View style={{ gap: 10, flexDirection: 'row', alignItems: 'flex-end' }} > diff --git a/packages/desktop-client/src/style.tsx b/packages/desktop-client/src/style.tsx index 7d5d61cc9d94105e416c80a2a3a22b6619eef645..84fa957e759efe1fb1848548bad4dc57a242245f 100644 --- a/packages/desktop-client/src/style.tsx +++ b/packages/desktop-client/src/style.tsx @@ -97,7 +97,7 @@ export const styles = { }, smallText: { fontSize: 13, - [`@media (min-width: ${tokens.breakpoint_medium})`]: { + [`@media (min-width: ${tokens.breakpoint_small})`]: { // lineHeight: 21 // TODO: This seems like trouble, but what's the right value? }, }, @@ -105,16 +105,12 @@ export const styles = { fontSize: 13, }, page: { - // This is the height of the titlebar - paddingTop: 8, flex: 1, - [`@media (min-width: ${tokens.breakpoint_xs})`]: { - minWidth: 360, + minHeight: 700, // ensure we can scroll on small screens + paddingTop: 8, // height of the titlebar + [`@media (min-width: ${tokens.breakpoint_small})`]: { paddingTop: 36, }, - [`@media (min-width: ${tokens.breakpoint_medium})`]: { - minWidth: 500, - }, }, pageHeader: { fontSize: 25, @@ -129,14 +125,14 @@ export const styles = { pageContent: { paddingLeft: 2, paddingRight: 2, - [`@media (min-width: ${tokens.breakpoint_medium})`]: { + [`@media (min-width: ${tokens.breakpoint_small})`]: { paddingLeft: 20, paddingRight: 20, }, }, settingsPageContent: { padding: 20, - [`@media (min-width: ${tokens.breakpoint_medium})`]: { + [`@media (min-width: ${tokens.breakpoint_small})`]: { padding: 'inherit', }, }, diff --git a/packages/desktop-client/src/tokens.js b/packages/desktop-client/src/tokens.js deleted file mode 100644 index de4c6eec4e3d454f745461d35b69d1fa042bb687..0000000000000000000000000000000000000000 --- a/packages/desktop-client/src/tokens.js +++ /dev/null @@ -1,14 +0,0 @@ -const tokens = { - breakpoint_xs: '350px', - breakpoint_narrow: '512px', - breakpoint_medium: '768px', - breakpoint_wide: '1024px', -}; -export default tokens; - -export const breakpoints = { - xs: 350, - narrow: 512, - medium: 768, - wide: 1024, -}; diff --git a/packages/desktop-client/src/tokens.ts b/packages/desktop-client/src/tokens.ts new file mode 100644 index 0000000000000000000000000000000000000000..280dc7f9eb7cd96b1fc1fe2f9ab4875f05a7f4d7 --- /dev/null +++ b/packages/desktop-client/src/tokens.ts @@ -0,0 +1,34 @@ +enum BreakpointNames { + small = 'small', + medium = 'medium', + wide = 'wide', +} + +type NumericBreakpoints = { + [key in BreakpointNames]: number; +}; + +export const breakpoints: NumericBreakpoints = { + small: 512, + medium: 730, + wide: 1100, +}; + +type BreakpointsPx = { + [B in keyof NumericBreakpoints as `breakpoint_${B}`]: string; +}; + +// Provide the same breakpoints in a form usable by CSS media queries +// { +// breakpoint_small: '512px', +// breakpoint_medium: '740px', +// breakpoint_wide: '1100px', +// } +const breakpointsInPx: BreakpointsPx = Object.entries( + breakpoints, +).reduce<BreakpointsPx>((acc, [key, val]) => { + acc[`breakpoint_${key}`] = `${val}px`; + return acc; +}, {} as BreakpointsPx); + +export default breakpointsInPx; diff --git a/packages/desktop-client/src/util.js b/packages/desktop-client/src/util.js index bde05fce7d48b3cc9cef1c412aa94837b5eb5701..35e1fadd11506164e30c21d9c2fa24526385daf3 100644 --- a/packages/desktop-client/src/util.js +++ b/packages/desktop-client/src/util.js @@ -2,9 +2,3 @@ export function getModalRoute(name) { let parts = name.split('/'); return [parts[0], parts.slice(1).join('/')]; } - -export function isMobile() { - let details = navigator.userAgent; - let regexp = /Mobi|android|iphone|kindle|ipad/i; - return regexp.test(details); -} diff --git a/upcoming-release-notes/964.md b/upcoming-release-notes/964.md new file mode 100644 index 0000000000000000000000000000000000000000..be23c35bb1e59896d6c6536ca5e8c27c74ba6032 --- /dev/null +++ b/upcoming-release-notes/964.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [trevdor] +--- + +Introduces a ResponsiveProvider as a foundation for future mobile development. Makes transaction entry available to many mobile users in landscape orientation.