diff --git a/apps/admin/package.json b/apps/admin/package.json index 635527ee0b3..4681bd32d86 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -30,8 +30,8 @@ "next": "^16.0.10", "next-themes": "^0.4.6", "posthog-js": "1.310.1", - "react": "19.1.0", - "react-dom": "19.1.0", + "react": "19.2.0", + "react-dom": "19.2.0", "react-icons": "^5.5.0", "recharts": "2.15.4", "require-in-the-middle": "8.0.1", @@ -43,7 +43,7 @@ "@superset/typescript": "workspace:*", "@tailwindcss/postcss": "^4.0.9", "@types/node": "^24.9.1", - "@types/react": "~19.1.0", + "@types/react": "~19.2.2", "@types/react-dom": "^19.2.3", "babel-plugin-react-compiler": "^1.0.0", "dotenv": "^17.2.3", diff --git a/apps/api/package.json b/apps/api/package.json index aeebf88ef76..7d99e520ae6 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -39,8 +39,8 @@ "lodash.chunk": "^4.2.0", "mcp-handler": "^1.0.7", "next": "^16.0.10", - "react": "19.1.0", - "react-dom": "19.1.0", + "react": "19.2.0", + "react-dom": "19.2.0", "require-in-the-middle": "8.0.1", "zod": "^4.3.5" }, @@ -48,7 +48,7 @@ "@superset/typescript": "workspace:*", "@types/lodash.chunk": "^4.2.9", "@types/node": "^24.9.1", - "@types/react": "~19.1.0", + "@types/react": "~19.2.2", "@types/react-dom": "^19.2.3", "babel-plugin-react-compiler": "^1.0.0", "dotenv": "^17.2.3", diff --git a/apps/cli/package.json b/apps/cli/package.json index 44d3ed792b6..9097b009202 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -33,13 +33,13 @@ "ink-text-input": "^6.0.0", "lowdb": "^7.0.1", "meow": "^11.0.0", - "react": "19.1.0", + "react": "19.2.0", "react-devtools-core": "^7.0.1", "string-width": "^8.1.0" }, "devDependencies": { "@superset/typescript": "workspace:*", - "@types/react": "~19.1.0", + "@types/react": "~19.2.2", "bun-types": "^1.3.1", "chalk": "^5.6.2", "chokidar": "^3.5.3", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 50961257b68..ded0cbc1ed9 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -145,10 +145,10 @@ "posthog-js": "1.310.1", "posthog-node": "^5.24.7", "prebuild-install": "^7.1.1", - "react": "19.1.0", + "react": "19.2.0", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", - "react-dom": "19.1.0", + "react-dom": "19.2.0", "react-hook-form": "^7.71.1", "react-icons": "^5.5.0", "react-markdown": "^10.1.0", @@ -184,7 +184,7 @@ "@types/http-proxy": "^1.17.17", "@types/lodash": "^4.17.20", "@types/node": "^24.9.1", - "@types/react": "~19.1.0", + "@types/react": "~19.2.2", "@types/react-dom": "^19.2.3", "@types/react-syntax-highlighter": "^15.5.13", "@types/semver": "^7.7.1", diff --git a/apps/docs/package.json b/apps/docs/package.json index 80198f68a71..cbbd674eb1d 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -25,8 +25,8 @@ "lucide-react": "^0.563.0", "next": "^16.0.10", "posthog-js": "1.310.1", - "react": "19.1.0", - "react-dom": "19.1.0", + "react": "19.2.0", + "react-dom": "19.2.0", "tailwind-merge": "^3.4.0", "zod": "^4.3.5" }, @@ -35,7 +35,7 @@ "@tailwindcss/postcss": "^4.0.9", "@types/mdx": "^2.0.13", "@types/node": "^24.9.1", - "@types/react": "~19.1.0", + "@types/react": "~19.2.2", "@types/react-dom": "^19.2.3", "postcss": "^8.5.6", "tailwindcss": "^4.1.18", diff --git a/apps/marketing/package.json b/apps/marketing/package.json index ea372936126..f556707bde7 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -27,8 +27,8 @@ "next-mdx-remote": "^5.0.0", "next-themes": "^0.4.6", "posthog-js": "1.310.1", - "react": "19.1.0", - "react-dom": "19.1.0", + "react": "19.2.0", + "react-dom": "19.2.0", "react-fast-marquee": "^1.6.5", "react-icons": "^5.5.0", "remark-gfm": "^4.0.1", @@ -46,7 +46,7 @@ "@tailwindcss/typography": "^0.5.16", "@types/mdx": "^2.0.13", "@types/node": "^24.9.1", - "@types/react": "~19.1.0", + "@types/react": "~19.2.2", "@types/react-dom": "^19.2.3", "@types/three": "^0.181.0", "@types/ua-parser-js": "^0.7.39", diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 3ce0586de32..d624a77eede 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -16,13 +16,12 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ version: "1.0.0", orientation: "portrait", icon: "./assets/icon.png", - userInterfaceStyle: "light", - newArchEnabled: true, + userInterfaceStyle: "dark", scheme: "superset", splash: { image: "./assets/splash-icon.png", resizeMode: "contain", - backgroundColor: "#ffffff", + backgroundColor: "#09090b", }, ios: { supportsTablet: true, @@ -37,7 +36,6 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ backgroundColor: "#ffffff", }, package: "sh.superset.mobile", - edgeToEdgeEnabled: true, predictiveBackGestureEnabled: false, }, web: { diff --git a/apps/mobile/app/(auth)/sign-in.tsx b/apps/mobile/app/(auth)/sign-in.tsx index 55c70636266..552e399c648 100644 --- a/apps/mobile/app/(auth)/sign-in.tsx +++ b/apps/mobile/app/(auth)/sign-in.tsx @@ -1,13 +1,3 @@ -import { Redirect } from "expo-router"; -import { useSession } from "@/lib/auth/client"; import { SignInScreen } from "@/screens/(auth)/sign-in"; -export default function SignInRoute() { - const { data: session } = useSession(); - - if (session) { - return ; - } - - return ; -} +export default SignInScreen; diff --git a/apps/mobile/app/(authenticated)/(home)/_layout.tsx b/apps/mobile/app/(authenticated)/(home)/_layout.tsx new file mode 100644 index 00000000000..a8423687477 --- /dev/null +++ b/apps/mobile/app/(authenticated)/(home)/_layout.tsx @@ -0,0 +1,15 @@ +import { Stack } from "expo-router"; + +export default function HomeLayout() { + return ( + + + + + ); +} diff --git a/apps/mobile/app/(authenticated)/(home)/index.tsx b/apps/mobile/app/(authenticated)/(home)/index.tsx new file mode 100644 index 00000000000..9cd0c421bb1 --- /dev/null +++ b/apps/mobile/app/(authenticated)/(home)/index.tsx @@ -0,0 +1,3 @@ +import { WorkspacesScreen } from "@/screens/(authenticated)/(home)/workspaces"; + +export default WorkspacesScreen; diff --git a/apps/mobile/app/(authenticated)/(home)/workspaces/[id].tsx b/apps/mobile/app/(authenticated)/(home)/workspaces/[id].tsx new file mode 100644 index 00000000000..f6fcec899b6 --- /dev/null +++ b/apps/mobile/app/(authenticated)/(home)/workspaces/[id].tsx @@ -0,0 +1,3 @@ +import { WorkspaceDetailScreen } from "@/screens/(authenticated)/workspaces/[id]"; + +export default WorkspaceDetailScreen; diff --git a/apps/mobile/app/(authenticated)/(more)/_layout.tsx b/apps/mobile/app/(authenticated)/(more)/_layout.tsx new file mode 100644 index 00000000000..3b5dea7154c --- /dev/null +++ b/apps/mobile/app/(authenticated)/(more)/_layout.tsx @@ -0,0 +1,10 @@ +import { Stack } from "expo-router"; + +export default function MoreLayout() { + return ( + + + + + ); +} diff --git a/apps/mobile/app/(authenticated)/(more)/index.tsx b/apps/mobile/app/(authenticated)/(more)/index.tsx new file mode 100644 index 00000000000..a98d7dc24ad --- /dev/null +++ b/apps/mobile/app/(authenticated)/(more)/index.tsx @@ -0,0 +1,3 @@ +import { MoreMenuScreen } from "@/screens/(authenticated)/(more)"; + +export default MoreMenuScreen; diff --git a/apps/mobile/app/(authenticated)/(more)/settings.tsx b/apps/mobile/app/(authenticated)/(more)/settings.tsx new file mode 100644 index 00000000000..bdbb4862e81 --- /dev/null +++ b/apps/mobile/app/(authenticated)/(more)/settings.tsx @@ -0,0 +1,3 @@ +import { SettingsScreen } from "@/screens/(authenticated)/(more)/settings"; + +export default SettingsScreen; diff --git a/apps/mobile/app/(authenticated)/(tasks)/[id].tsx b/apps/mobile/app/(authenticated)/(tasks)/[id].tsx new file mode 100644 index 00000000000..8a395863763 --- /dev/null +++ b/apps/mobile/app/(authenticated)/(tasks)/[id].tsx @@ -0,0 +1,3 @@ +import { TaskDetailScreen } from "@/screens/(authenticated)/tasks/[id]"; + +export default TaskDetailScreen; diff --git a/apps/mobile/app/(authenticated)/(tasks)/_layout.tsx b/apps/mobile/app/(authenticated)/(tasks)/_layout.tsx new file mode 100644 index 00000000000..48699a5ceec --- /dev/null +++ b/apps/mobile/app/(authenticated)/(tasks)/_layout.tsx @@ -0,0 +1,10 @@ +import { Stack } from "expo-router"; + +export default function TasksLayout() { + return ( + + + + + ); +} diff --git a/apps/mobile/app/(authenticated)/(tasks)/index.tsx b/apps/mobile/app/(authenticated)/(tasks)/index.tsx new file mode 100644 index 00000000000..5b2bc795e04 --- /dev/null +++ b/apps/mobile/app/(authenticated)/(tasks)/index.tsx @@ -0,0 +1,3 @@ +import { TasksScreen } from "@/screens/(authenticated)/(tasks)/tasks"; + +export default TasksScreen; diff --git a/apps/mobile/app/(authenticated)/_layout.tsx b/apps/mobile/app/(authenticated)/_layout.tsx index 30806acfeb3..b528f8798f1 100644 --- a/apps/mobile/app/(authenticated)/_layout.tsx +++ b/apps/mobile/app/(authenticated)/_layout.tsx @@ -1,13 +1,22 @@ -import { Stack } from "expo-router"; +import { TabList, TabSlot, Tabs, TabTrigger } from "expo-router/ui"; import { useDevicePresence } from "@/hooks/useDevicePresence"; -import { CollectionsProvider } from "@/providers/CollectionsProvider"; +import { AuthenticatedTabBar } from "@/screens/(authenticated)/components/AuthenticatedTabBar"; +import { CollectionsProvider } from "@/screens/(authenticated)/providers/CollectionsProvider"; export default function AuthenticatedLayout() { useDevicePresence(); return ( - + + + + + + + + + ); } diff --git a/apps/mobile/app/(authenticated)/demo.tsx b/apps/mobile/app/(authenticated)/demo.tsx deleted file mode 100644 index dfb88551d19..00000000000 --- a/apps/mobile/app/(authenticated)/demo.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { DemoScreen } from "@/screens/(authenticated)/demo"; - -export default DemoScreen; diff --git a/apps/mobile/app/(authenticated)/index.tsx b/apps/mobile/app/(authenticated)/index.tsx deleted file mode 100644 index b75369e1edc..00000000000 --- a/apps/mobile/app/(authenticated)/index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { HomeScreen } from "@/screens/(authenticated)/index"; - -export default HomeScreen; diff --git a/apps/mobile/app/index.tsx b/apps/mobile/app/index.tsx deleted file mode 100644 index 52fe54360d9..00000000000 --- a/apps/mobile/app/index.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { Redirect } from "expo-router"; -import { useSession } from "@/lib/auth/client"; - -export default function Index() { - const { data: session } = useSession(); - - if (!session) { - return ; - } - - return ; -} diff --git a/apps/mobile/assets/social/github.svg b/apps/mobile/assets/social/github.svg new file mode 100644 index 00000000000..c1ef8e53e03 --- /dev/null +++ b/apps/mobile/assets/social/github.svg @@ -0,0 +1 @@ + diff --git a/apps/mobile/assets/social/google.svg b/apps/mobile/assets/social/google.svg new file mode 100644 index 00000000000..b372f3248e8 --- /dev/null +++ b/apps/mobile/assets/social/google.svg @@ -0,0 +1 @@ + diff --git a/apps/mobile/components/OrganizationSwitcher/OrganizationSwitcher.tsx b/apps/mobile/components/OrganizationSwitcher/OrganizationSwitcher.tsx deleted file mode 100644 index 63c15653f82..00000000000 --- a/apps/mobile/components/OrganizationSwitcher/OrganizationSwitcher.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { useLiveQuery } from "@tanstack/react-db"; -import { useState } from "react"; -import { View } from "react-native"; -import { authClient } from "@/lib/auth/client"; -import { useCollections } from "@/providers/CollectionsProvider"; -import { Button } from "../ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "../ui/card"; -import { Text } from "../ui/text"; - -export function OrganizationSwitcher() { - const collections = useCollections(); - const [switching, setSwitching] = useState(false); - - // Get all organizations - const { data: orgs } = useLiveQuery( - (q) => q.from({ organizations: collections.organizations }), - [collections], - ); - - // Get current session to know active org - const session = authClient.useSession(); - const activeOrgId = session.data?.session?.activeOrganizationId; - - const handleSwitchOrg = async (orgId: string) => { - if (orgId === activeOrgId) return; - - setSwitching(true); - try { - await authClient.organization.setActive({ - organizationId: orgId, - }); - // Refresh the page to reload collections - // Note: In React Native, we might need to handle this differently - } catch (error) { - console.error("Failed to switch organization:", error); - } finally { - setSwitching(false); - } - }; - - return ( - - - Organizations - - {orgs?.length || 0} organization(s) available - - - - {orgs?.map((org) => { - const isActive = org.id === activeOrgId; - return ( - - - - {org.name} - {org.slug && ( - - @{org.slug} - - )} - - {isActive ? ( - - Active - - ) : ( - - )} - - - ); - })} - - - ); -} diff --git a/apps/mobile/components/OrganizationSwitcher/index.ts b/apps/mobile/components/OrganizationSwitcher/index.ts deleted file mode 100644 index 05deafcfd8b..00000000000 --- a/apps/mobile/components/OrganizationSwitcher/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { OrganizationSwitcher } from "./OrganizationSwitcher"; diff --git a/apps/mobile/global.d.ts b/apps/mobile/global.d.ts new file mode 100644 index 00000000000..99185e10286 --- /dev/null +++ b/apps/mobile/global.d.ts @@ -0,0 +1,5 @@ +declare module "*.svg" { + import type { ImageSourcePropType } from "react-native"; + const value: ImageSourcePropType; + export default value; +} diff --git a/apps/mobile/hooks/useSignOut/index.ts b/apps/mobile/hooks/useSignOut/index.ts new file mode 100644 index 00000000000..a1a52d54189 --- /dev/null +++ b/apps/mobile/hooks/useSignOut/index.ts @@ -0,0 +1 @@ +export { useSignOut } from "./useSignOut"; diff --git a/apps/mobile/hooks/useSignOut/useSignOut.ts b/apps/mobile/hooks/useSignOut/useSignOut.ts new file mode 100644 index 00000000000..199e3347bf8 --- /dev/null +++ b/apps/mobile/hooks/useSignOut/useSignOut.ts @@ -0,0 +1,25 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "expo-router"; +import { useCallback, useState } from "react"; +import { signOut } from "@/lib/auth/client"; + +export function useSignOut() { + const router = useRouter(); + const queryClient = useQueryClient(); + const [isSigningOut, setIsSigningOut] = useState(false); + + const handleSignOut = useCallback(async () => { + setIsSigningOut(true); + try { + await signOut(); + queryClient.clear(); + router.replace("/(auth)/sign-in"); + } catch (error) { + console.error("[auth/signOut] Failed to sign out:", error); + } finally { + setIsSigningOut(false); + } + }, [router, queryClient]); + + return { signOut: handleSignOut, isSigningOut }; +} diff --git a/apps/mobile/hooks/useTheme/index.ts b/apps/mobile/hooks/useTheme/index.ts new file mode 100644 index 00000000000..e1fd4b31d16 --- /dev/null +++ b/apps/mobile/hooks/useTheme/index.ts @@ -0,0 +1 @@ +export { useTheme } from "./useTheme"; diff --git a/apps/mobile/hooks/useTheme/useTheme.ts b/apps/mobile/hooks/useTheme/useTheme.ts new file mode 100644 index 00000000000..f83856cad3b --- /dev/null +++ b/apps/mobile/hooks/useTheme/useTheme.ts @@ -0,0 +1,7 @@ +import { useUniwind } from "uniwind"; +import { THEME } from "@/lib/theme"; + +export function useTheme() { + const { theme } = useUniwind(); + return THEME[theme]; +} diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index 7b15766c5b2..beb8c9486c1 100644 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -19,6 +19,11 @@ config.resolver.nodeModulesPaths = [ // Enable package exports for better-auth config.resolver.unstable_enablePackageExports = true; +// Resolve local Expo Modules (modules/ dir) +config.resolver.extraNodeModules = { + "@superset/tab-bar": path.resolve(projectRoot, "modules/tab-bar"), +}; + module.exports = withUniwindConfig(config, { cssEntryFile: "./global.css", dtsFile: "./uniwind-types.d.ts", diff --git a/apps/mobile/modules/tab-bar/expo-module.config.json b/apps/mobile/modules/tab-bar/expo-module.config.json new file mode 100644 index 00000000000..bdda958af2e --- /dev/null +++ b/apps/mobile/modules/tab-bar/expo-module.config.json @@ -0,0 +1,5 @@ +{ + "platforms": ["apple"], + "coreFeatures": ["swiftui"], + "apple": { "modules": ["TabBarModule"] } +} diff --git a/apps/mobile/modules/tab-bar/ios/CollapsedBarView.swift b/apps/mobile/modules/tab-bar/ios/CollapsedBarView.swift new file mode 100644 index 00000000000..3d85d84e5d1 --- /dev/null +++ b/apps/mobile/modules/tab-bar/ios/CollapsedBarView.swift @@ -0,0 +1,56 @@ +import SwiftUI + +struct CollapsedBarView: View { + let tabs: [TabItem] + let selectedTab: String + let onTabTap: (String) -> Void + let onMenuTriggerTap: () -> Void + + var body: some View { + HStack(spacing: 0) { + ForEach(Array(tabs.enumerated()), id: \.element.name) { _, tab in + Button { + if tab.isMenuTrigger { + onMenuTriggerTap() + } else { + onTabTap(tab.name) + } + } label: { + ZStack { + if !tab.isMenuTrigger && tab.name == selectedTab { + RoundedRectangle(cornerRadius: 10) + .fill(Color.white.opacity(0.12)) + .frame(width: 36, height: 36) + } + + VStack(spacing: 2) { + ZStack(alignment: .topTrailing) { + Image(systemName: tab.icon) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle( + !tab.isMenuTrigger && tab.name == selectedTab + ? .white + : .white.opacity(0.6) + ) + + if let badge = tab.badge, badge > 0 { + Text("\(badge)") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(Color.red, in: Capsule()) + .offset(x: 8, y: -6) + } + } + } + } + .frame(width: 48, height: 48) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 4) + .background(.ultraThinMaterial, in: Capsule()) + } +} diff --git a/apps/mobile/modules/tab-bar/ios/ExpandedMenuView.swift b/apps/mobile/modules/tab-bar/ios/ExpandedMenuView.swift new file mode 100644 index 00000000000..8310db93482 --- /dev/null +++ b/apps/mobile/modules/tab-bar/ios/ExpandedMenuView.swift @@ -0,0 +1,101 @@ +import SwiftUI + +struct ExpandedMenuView: View { + let tabs: [TabItem] + let menuActions: [MenuAction] + let selectedTab: String + let organizationName: String + let onTabTap: (String) -> Void + let onMenuActionTap: (String) -> Void + let onSettingsTap: () -> Void + let onOrgTap: () -> Void + + var body: some View { + VStack(spacing: 12) { + // Top row: org switcher + settings + HStack { + Button(action: onOrgTap) { + HStack(spacing: 6) { + Image(systemName: "diamond.fill") + .font(.system(size: 10)) + Text(organizationName) + .font(.system(size: 14, weight: .medium)) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 10)) + } + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.ultraThinMaterial, in: Capsule()) + } + .buttonStyle(.plain) + + Spacer() + + GlassCircleButton(icon: "gearshape.fill") { + onSettingsTap() + } + } + + // Menu body + VStack(spacing: 0) { + ForEach(Array(tabs.filter { !$0.isMenuTrigger }.enumerated()), id: \.element.name) { _, tab in + Button { + onTabTap(tab.name) + } label: { + HStack(spacing: 12) { + Image(systemName: tab.icon) + .font(.system(size: 16, weight: .medium)) + .frame(width: 24) + Text(tab.label) + .font(.system(size: 16, weight: .regular)) + Spacer() + if let badge = tab.badge, badge > 0 { + Text("\(badge)") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.white.opacity(0.7)) + } + } + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background( + tab.name == selectedTab + ? Color.white.opacity(0.12) + : Color.clear, + in: RoundedRectangle(cornerRadius: 8) + ) + } + .buttonStyle(.plain) + } + + if !menuActions.isEmpty { + Divider() + .background(Color.white.opacity(0.2)) + .padding(.vertical, 4) + + ForEach(Array(menuActions.enumerated()), id: \.element.name) { _, action in + Button { + onMenuActionTap(action.name) + } label: { + HStack(spacing: 12) { + Image(systemName: action.icon) + .font(.system(size: 16, weight: .medium)) + .frame(width: 24) + Text(action.label) + .font(.system(size: 16, weight: .regular)) + Spacer() + } + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 10) + } + .buttonStyle(.plain) + } + } + } + .padding(8) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16)) + } + } +} diff --git a/apps/mobile/modules/tab-bar/ios/GlassCircleButton.swift b/apps/mobile/modules/tab-bar/ios/GlassCircleButton.swift new file mode 100644 index 00000000000..3aaac3f23b5 --- /dev/null +++ b/apps/mobile/modules/tab-bar/ios/GlassCircleButton.swift @@ -0,0 +1,16 @@ +import SwiftUI + +struct GlassCircleButton: View { + let icon: String + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: icon) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(.white) + .frame(width: 44, height: 44) + .background(.ultraThinMaterial, in: Circle()) + } + } +} diff --git a/apps/mobile/modules/tab-bar/ios/TabBar.podspec b/apps/mobile/modules/tab-bar/ios/TabBar.podspec new file mode 100644 index 00000000000..cd18bbb567d --- /dev/null +++ b/apps/mobile/modules/tab-bar/ios/TabBar.podspec @@ -0,0 +1,25 @@ +require 'json' + +package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json'))) + +Pod::Spec.new do |s| + s.name = 'TabBar' + s.version = package['version'] + s.summary = 'Custom floating tab bar for Superset' + s.description = 'A SwiftUI-based floating tab bar with expandable menu' + s.license = 'MIT' + s.author = 'Superset' + s.homepage = 'https://superset.sh' + s.platforms = { :ios => '15.1' } + s.swift_version = '5.9' + s.source = { git: 'https://github.com/nicksupersetsh/superset.git' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES' + } + + s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" +end diff --git a/apps/mobile/modules/tab-bar/ios/TabBarModule.swift b/apps/mobile/modules/tab-bar/ios/TabBarModule.swift new file mode 100644 index 00000000000..124a496acf2 --- /dev/null +++ b/apps/mobile/modules/tab-bar/ios/TabBarModule.swift @@ -0,0 +1,8 @@ +import ExpoModulesCore + +public final class TabBarModule: Module { + public func definition() -> ModuleDefinition { + Name("TabBar") + View(TabBarView.self) + } +} diff --git a/apps/mobile/modules/tab-bar/ios/TabBarProps.swift b/apps/mobile/modules/tab-bar/ios/TabBarProps.swift new file mode 100644 index 00000000000..44d9e3eff44 --- /dev/null +++ b/apps/mobile/modules/tab-bar/ios/TabBarProps.swift @@ -0,0 +1,28 @@ +import ExpoModulesCore + +struct TabItem: Record { + @Field var name: String = "" + @Field var icon: String = "" + @Field var label: String = "" + @Field var badge: Int? + @Field var isMenuTrigger: Bool = false +} + +struct MenuAction: Record { + @Field var name: String = "" + @Field var icon: String = "" + @Field var label: String = "" +} + +final class TabBarProps: ExpoSwiftUI.ViewProps { + @Field var tabs: [TabItem] = [] + @Field var menuActions: [MenuAction] = [] + @Field var selectedTab: String = "" + @Field var organizationName: String = "" + var onTabSelect = EventDispatcher() + var onMenuActionPress = EventDispatcher() + var onSettingsPress = EventDispatcher() + var onSearchPress = EventDispatcher() + var onOrgPress = EventDispatcher() + var onExpandedChange = EventDispatcher() +} diff --git a/apps/mobile/modules/tab-bar/ios/TabBarView.swift b/apps/mobile/modules/tab-bar/ios/TabBarView.swift new file mode 100644 index 00000000000..e097196f192 --- /dev/null +++ b/apps/mobile/modules/tab-bar/ios/TabBarView.swift @@ -0,0 +1,96 @@ +import SwiftUI +import ExpoModulesCore + +struct TabBarView: ExpoSwiftUI.View { + @ObservedObject var props: TabBarProps + @State private var isExpanded: Bool = false + + init(props: TabBarProps) { + self.props = props + } + + var body: some View { + ZStack(alignment: .bottom) { + // Dismiss overlay when expanded + if isExpanded { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { + isExpanded = false + } + } + .ignoresSafeArea() + } + + // Main content + HStack(alignment: .bottom) { + if isExpanded { + ExpandedMenuView( + tabs: props.tabs, + menuActions: props.menuActions, + selectedTab: props.selectedTab, + organizationName: props.organizationName, + onTabTap: { name in + props.onTabSelect(["name": name]) + withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { + isExpanded = false + } + }, + onMenuActionTap: { name in + props.onMenuActionPress(["name": name]) + withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { + isExpanded = false + } + }, + onSettingsTap: { + props.onSettingsPress() + withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { + isExpanded = false + } + }, + onOrgTap: { + props.onOrgPress() + withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { + isExpanded = false + } + } + ) + .transition( + .scale(scale: 0.9, anchor: .bottomLeading) + .combined(with: .opacity) + ) + } else { + CollapsedBarView( + tabs: props.tabs, + selectedTab: props.selectedTab, + onTabTap: { name in + props.onTabSelect(["name": name]) + }, + onMenuTriggerTap: { + withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { + isExpanded = true + } + } + ) + .transition( + .scale(scale: 0.9, anchor: .bottomLeading) + .combined(with: .opacity) + ) + } + + Spacer() + + // Search button - always visible + GlassCircleButton(icon: "magnifyingglass") { + props.onSearchPress() + } + } + .padding(.horizontal, 16) + .padding(.bottom, 8) + } + .onChange(of: isExpanded) { newValue in + props.onExpandedChange(["expanded": newValue]) + } + } +} diff --git a/apps/mobile/modules/tab-bar/package.json b/apps/mobile/modules/tab-bar/package.json new file mode 100644 index 00000000000..f5745ef6cbb --- /dev/null +++ b/apps/mobile/modules/tab-bar/package.json @@ -0,0 +1,11 @@ +{ + "name": "@superset/tab-bar", + "version": "0.0.1", + "main": "src/index.tsx", + "private": true, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } +} diff --git a/apps/mobile/modules/tab-bar/src/TabBarView.tsx b/apps/mobile/modules/tab-bar/src/TabBarView.tsx new file mode 100644 index 00000000000..b43ae9a44c0 --- /dev/null +++ b/apps/mobile/modules/tab-bar/src/TabBarView.tsx @@ -0,0 +1,49 @@ +import { Host } from "@expo/ui/swift-ui"; +import { requireNativeView } from "expo"; +import type { NativeSyntheticEvent } from "react-native"; +import type { TabBarViewProps } from "./TabBarView.types"; + +type NativeTabBarViewProps = Omit< + TabBarViewProps, + "onTabSelect" | "onMenuActionPress" | "onExpandedChange" | "style" +> & { + onTabSelect: (event: NativeSyntheticEvent<{ name: string }>) => void; + onMenuActionPress: (event: NativeSyntheticEvent<{ name: string }>) => void; + onExpandedChange: ( + event: NativeSyntheticEvent<{ expanded: boolean }>, + ) => void; +}; + +const NativeView: React.ComponentType = + requireNativeView("TabBar"); + +export function TabBarView({ + onTabSelect, + onMenuActionPress, + onExpandedChange, + style, + ...props +}: TabBarViewProps) { + return ( + + ) => { + onTabSelect?.(name); + }} + onMenuActionPress={({ + nativeEvent: { name }, + }: NativeSyntheticEvent<{ name: string }>) => { + onMenuActionPress?.(name); + }} + onExpandedChange={({ + nativeEvent: { expanded }, + }: NativeSyntheticEvent<{ expanded: boolean }>) => { + onExpandedChange?.(expanded); + }} + /> + + ); +} diff --git a/apps/mobile/modules/tab-bar/src/TabBarView.types.ts b/apps/mobile/modules/tab-bar/src/TabBarView.types.ts new file mode 100644 index 00000000000..1d4c9838e88 --- /dev/null +++ b/apps/mobile/modules/tab-bar/src/TabBarView.types.ts @@ -0,0 +1,27 @@ +export interface TabItem { + name: string; + icon: string; + label: string; + badge?: number; + isMenuTrigger?: boolean; +} + +export interface MenuAction { + name: string; + icon: string; + label: string; +} + +export interface TabBarViewProps { + tabs: TabItem[]; + menuActions?: MenuAction[]; + selectedTab: string; + organizationName: string; + style?: import("react-native").StyleProp; + onTabSelect?: (tab: string) => void; + onMenuActionPress?: (action: string) => void; + onSettingsPress?: () => void; + onSearchPress?: () => void; + onOrgPress?: () => void; + onExpandedChange?: (expanded: boolean) => void; +} diff --git a/apps/mobile/modules/tab-bar/src/index.tsx b/apps/mobile/modules/tab-bar/src/index.tsx new file mode 100644 index 00000000000..24c45c88210 --- /dev/null +++ b/apps/mobile/modules/tab-bar/src/index.tsx @@ -0,0 +1,2 @@ +export { TabBarView } from "./TabBarView"; +export type { MenuAction, TabBarViewProps, TabItem } from "./TabBarView.types"; diff --git a/apps/mobile/package.json b/apps/mobile/package.json index cc0d87a4e6b..82e58cfa831 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "main": "expo-router/entry", "scripts": { - "start": "expo start", + "dev": "EXPO_UNSTABLE_MCP_SERVER=1 expo start", "android": "expo run:android", "ios": "expo run:ios", "web": "expo start --web", @@ -14,8 +14,10 @@ "dependencies": { "@better-auth/expo": "1.4.18", "@electric-sql/client": "https://pkg.pr.new/@electric-sql/client@3724", + "@expo/ui": "~55.0.0-preview.4", + "@expo/vector-icons": "^15.0.3", "@react-native-async-storage/async-storage": "2.2.0", - "@react-navigation/native": "^7.1.28", + "@react-navigation/native": "^7.1.8", "@rn-primitives/accordion": "^1.2.0", "@rn-primitives/alert-dialog": "^1.2.0", "@rn-primitives/aspect-ratio": "^1.2.0", @@ -52,31 +54,34 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dotenv": "^17.2.3", - "expo": "~54.0.31", - "expo-application": "~7.0.8", - "expo-constants": "^18.0.13", - "expo-crypto": "^15.0.8", - "expo-dev-client": "~6.0.20", - "expo-device": "~8.0.10", - "expo-file-system": "~19.0.21", - "expo-linking": "~8.0.11", - "expo-localization": "~17.0.8", - "expo-network": "~8.0.8", - "expo-router": "^6.0.21", - "expo-secure-store": "~15.0.8", - "expo-status-bar": "^3.0.9", - "expo-system-ui": "~6.0.9", - "expo-web-browser": "~15.0.10", + "expo": "^55.0.0-beta", + "expo-application": "~55.0.4", + "expo-constants": "~55.0.3", + "expo-crypto": "~55.0.4", + "expo-dev-client": "~55.0.4", + "expo-device": "~55.0.5", + "expo-file-system": "~55.0.4", + "expo-glass-effect": "~55.0.4", + "expo-image": "~55.0.3", + "expo-linking": "~55.0.3", + "expo-localization": "~55.0.4", + "expo-network": "~55.0.4", + "expo-router": "~55.0.0-preview.6", + "expo-secure-store": "~55.0.4", + "expo-status-bar": "~55.0.2", + "expo-system-ui": "~55.0.4", + "expo-web-browser": "~55.0.4", "lucide-react-native": "^0.562.0", "posthog-react-native": "^4.23.0", - "react": "19.1.0", - "react-native": "0.81.5", + "react": "19.2.0", + "react-native": "0.83.1", + "react-native-gesture-handler": "3.0.0-beta.1", "react-native-get-random-values": "^1.11.0", - "react-native-reanimated": "~4.1.1", + "react-native-reanimated": "~4.2.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "^4.16.0", - "react-native-svg": "^15.12.1", - "react-native-worklets": "~0.5.0", + "react-native-svg": "15.15.1", + "react-native-worklets": "0.7.2", "superjson": "^2.2.5", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.18", @@ -86,8 +91,9 @@ }, "devDependencies": { "@types/node": "^24.9.1", - "@types/react": "~19.1.0", - "babel-preset-expo": "^54.0.9", + "@types/react": "~19.2.2", + "babel-preset-expo": "~55.0.0", + "expo-mcp": "~0.2.1", "typescript": "^5.9.3" }, "private": true diff --git a/apps/mobile/screens/(auth)/sign-in/SignInScreen.tsx b/apps/mobile/screens/(auth)/sign-in/SignInScreen.tsx index 6e0d18e005b..bdeb3528dc1 100644 --- a/apps/mobile/screens/(auth)/sign-in/SignInScreen.tsx +++ b/apps/mobile/screens/(auth)/sign-in/SignInScreen.tsx @@ -1,79 +1,81 @@ import { useState } from "react"; -import { ActivityIndicator, Alert, View } from "react-native"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; +import { Image, Linking, View } from "react-native"; import { Text } from "@/components/ui/text"; import { signIn } from "@/lib/auth/client"; +import type { SocialProvider } from "./components/SocialButton"; +import { SocialButton } from "./components/SocialButton"; + +const TERMS_URL = "https://superset.sh/terms"; +const PRIVACY_URL = "https://superset.sh/privacy"; + export function SignInScreen() { - const [loading, setLoading] = useState<"github" | "google" | null>(null); + const [error, setError] = useState(null); - const handleSignIn = async (provider: "github" | "google") => { - console.log("[sign-in] Button clicked:", provider); + const handleSignIn = async (provider: SocialProvider) => { + setError(null); try { - setLoading(provider); - console.log("[sign-in] Calling signIn.social..."); - const result = await signIn.social({ + await signIn.social({ provider, callbackURL: "/", }); - console.log("[sign-in] signIn.social result:", result); - } catch (error) { - console.error("[sign-in] Error caught:", error); - console.error("[sign-in] Error details:", JSON.stringify(error, null, 2)); - Alert.alert( - "Sign In Failed", - error instanceof Error ? error.message : JSON.stringify(error), - ); - } finally { - setLoading(null); + } catch (err) { + const message = + err instanceof Error ? err.message : "Something went wrong"; + console.error("[sign-in] Error:", err); + setError(message); } }; return ( - - - - Welcome to Superset - Sign in to continue - - - + + + + + + Welcome to Superset + + + Sign in to get started + + + + + handleSignIn("github")} + className="w-4/5" + /> + handleSignIn("google")} + className="w-4/5" + /> + + + {error && ( + {error} + )} - - - + + By signing in, you agree to our{"\n"} + Linking.openURL(TERMS_URL)} + > + Terms of Service + {" "} + and{" "} + Linking.openURL(PRIVACY_URL)} + > + Privacy Policy + + ); } diff --git a/apps/mobile/screens/(auth)/sign-in/components/SocialButton/SocialButton.tsx b/apps/mobile/screens/(auth)/sign-in/components/SocialButton/SocialButton.tsx new file mode 100644 index 00000000000..6d9f02a5bbf --- /dev/null +++ b/apps/mobile/screens/(auth)/sign-in/components/SocialButton/SocialButton.tsx @@ -0,0 +1,76 @@ +import { useColorScheme } from "react-native"; +import Svg, { Path } from "react-native-svg"; +import type { ButtonProps } from "@/components/ui/button"; +import { Button } from "@/components/ui/button"; +import { Text } from "@/components/ui/text"; +import { cn } from "@/lib/utils"; + +export type SocialProvider = "github" | "google"; + +export interface SocialButtonProps extends Omit { + provider: SocialProvider; +} + +function GithubIcon({ color }: { color: string }) { + return ( + + + + ); +} + +function GoogleIcon() { + return ( + + + + + + + ); +} + +const PROVIDER_NAME: Record = { + github: "GitHub", + google: "Google", +}; + +export function SocialButton({ + provider, + className, + ...props +}: SocialButtonProps) { + const colorScheme = useColorScheme(); + const iconColor = colorScheme === "dark" ? "white" : "black"; + + return ( + + ); +} diff --git a/apps/mobile/screens/(auth)/sign-in/components/SocialButton/index.ts b/apps/mobile/screens/(auth)/sign-in/components/SocialButton/index.ts new file mode 100644 index 00000000000..717835727eb --- /dev/null +++ b/apps/mobile/screens/(auth)/sign-in/components/SocialButton/index.ts @@ -0,0 +1 @@ +export { SocialButton, type SocialProvider } from "./SocialButton"; diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/WorkspacesScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/WorkspacesScreen.tsx new file mode 100644 index 00000000000..4545341ba0c --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/WorkspacesScreen.tsx @@ -0,0 +1,66 @@ +import { useCallback, useState } from "react"; +import { + RefreshControl, + ScrollView, + useWindowDimensions, + View, +} from "react-native"; +import { Text } from "@/components/ui/text"; +import { useOrganizations } from "@/screens/(authenticated)/hooks/useOrganizations"; +import { OrganizationHeaderButton } from "./components/OrganizationHeaderButton"; +import { OrganizationSwitcherSheet } from "./components/OrganizationSwitcherSheet"; + +export function WorkspacesScreen() { + const [refreshing, setRefreshing] = useState(false); + const [sheetOpen, setSheetOpen] = useState(false); + const { width } = useWindowDimensions(); + const { + organizations, + activeOrganization, + activeOrganizationId, + switchOrganization, + } = useOrganizations(); + + const handleSwitchOrganization = (organizationId: string) => { + setSheetOpen(false); + switchOrganization(organizationId); + }; + + const onRefresh = useCallback(async () => { + setRefreshing(true); + setRefreshing(false); + }, []); + + return ( + <> + setSheetOpen(true)} + /> + + } + > + + + + Workspaces grouped by project will appear here + + + + + + + ); +} diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationHeaderButton/OrganizationHeaderButton.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationHeaderButton/OrganizationHeaderButton.tsx new file mode 100644 index 00000000000..8e200db8965 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationHeaderButton/OrganizationHeaderButton.tsx @@ -0,0 +1,34 @@ +import { Stack } from "expo-router"; +import { ChevronsUpDown } from "lucide-react-native"; +import { Pressable } from "react-native"; +import { Text } from "@/components/ui/text"; +import { OrganizationAvatar } from "../OrganizationSwitcherSheet/components/OrganizationAvatar"; + +export function OrganizationHeaderButton({ + name, + logo, + onPress, +}: { + name?: string; + logo?: string | null; + onPress: () => void; +}) { + return ( + <> + + + + + + {name ?? "Organization"} + + + + + + + {}} /> + + + ); +} diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationHeaderButton/index.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationHeaderButton/index.ts new file mode 100644 index 00000000000..ba3866494e0 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationHeaderButton/index.ts @@ -0,0 +1 @@ +export { OrganizationHeaderButton } from "./OrganizationHeaderButton"; diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationSwitcherSheet/OrganizationSwitcherSheet.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationSwitcherSheet/OrganizationSwitcherSheet.tsx new file mode 100644 index 00000000000..7bc7ee92c0f --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationSwitcherSheet/OrganizationSwitcherSheet.tsx @@ -0,0 +1,106 @@ +import { BottomSheet, Group, Host, RNHostView } from "@expo/ui/swift-ui"; +import { + background, + environment, + presentationDragIndicator, +} from "@expo/ui/swift-ui/modifiers"; +import Ionicons from "@expo/vector-icons/Ionicons"; +import { Pressable, View } from "react-native"; +import { Text } from "@/components/ui/text"; +import { useTheme } from "@/hooks/useTheme"; +import { OrganizationAvatar } from "./components/OrganizationAvatar"; + +export interface Organization { + id: string; + name: string; + slug?: string | null; + logo?: string | null; +} + +export function OrganizationSwitcherSheet({ + isPresented, + onIsPresentedChange, + organizations, + activeOrganizationId, + onSwitchOrganization, + width, +}: { + isPresented: boolean; + onIsPresentedChange: (value: boolean) => void; + organizations: Organization[]; + activeOrganizationId?: string | null; + onSwitchOrganization: (organizationId: string) => void; + width: number; +}) { + const theme = useTheme(); + + return ( + + + + + + + Organizations + + {organizations.map((organization) => { + const isActive = organization.id === activeOrganizationId; + return ( + onSwitchOrganization(organization.id)} + className="flex-row items-center gap-2.5 py-2.5" + > + + + + {organization.name} + + {organization.slug ? ( + + superset.sh/{organization.slug} + + ) : null} + + {isActive ? ( + + ) : null} + + ); + })} + + + + + + ); +} diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationSwitcherSheet/components/OrganizationAvatar/OrganizationAvatar.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationSwitcherSheet/components/OrganizationAvatar/OrganizationAvatar.tsx new file mode 100644 index 00000000000..c614c0cacc0 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationSwitcherSheet/components/OrganizationAvatar/OrganizationAvatar.tsx @@ -0,0 +1,44 @@ +import { Image, View } from "react-native"; +import { Text } from "@/components/ui/text"; +import { useTheme } from "@/hooks/useTheme"; + +export function OrganizationAvatar({ + name, + logo, + size, +}: { + name?: string | null; + logo?: string | null; + size: number; +}) { + const theme = useTheme(); + + if (logo) { + return ( + + ); + } + + const initial = (name ?? "O").charAt(0).toUpperCase(); + return ( + + + {initial} + + + ); +} diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationSwitcherSheet/components/OrganizationAvatar/index.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationSwitcherSheet/components/OrganizationAvatar/index.ts new file mode 100644 index 00000000000..e2f8476d567 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationSwitcherSheet/components/OrganizationAvatar/index.ts @@ -0,0 +1 @@ +export { OrganizationAvatar } from "./OrganizationAvatar"; diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationSwitcherSheet/index.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationSwitcherSheet/index.ts new file mode 100644 index 00000000000..3311d5d4c84 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationSwitcherSheet/index.ts @@ -0,0 +1 @@ +export { OrganizationSwitcherSheet } from "./OrganizationSwitcherSheet"; diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/index.ts b/apps/mobile/screens/(authenticated)/(home)/workspaces/index.ts new file mode 100644 index 00000000000..bf11998e5e7 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/index.ts @@ -0,0 +1 @@ +export { WorkspacesScreen } from "./WorkspacesScreen"; diff --git a/apps/mobile/screens/(authenticated)/(more)/MoreMenuScreen.tsx b/apps/mobile/screens/(authenticated)/(more)/MoreMenuScreen.tsx new file mode 100644 index 00000000000..69d291beefc --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(more)/MoreMenuScreen.tsx @@ -0,0 +1,139 @@ +import { useLiveQuery } from "@tanstack/react-db"; +import { useRouter } from "expo-router"; +import { + ArrowLeftRight, + ChevronRight, + LogOut, + Settings, +} from "lucide-react-native"; +import { useState } from "react"; +import { Pressable, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Icon } from "@/components/ui/icon"; +import { Separator } from "@/components/ui/separator"; +import { Text } from "@/components/ui/text"; +import { useSignOut } from "@/hooks/useSignOut"; +import { authClient } from "@/lib/auth/client"; +import { useCollections } from "@/screens/(authenticated)/providers/CollectionsProvider"; + +export function MoreMenuScreen() { + const router = useRouter(); + const insets = useSafeAreaInsets(); + const { signOut } = useSignOut(); + const collections = useCollections(); + const [switching, setSwitching] = useState(false); + + const session = authClient.useSession(); + const activeOrgId = session.data?.session?.activeOrganizationId; + + const { data: orgs } = useLiveQuery( + (q) => q.from({ organizations: collections.organizations }), + [collections], + ); + + const activeOrg = orgs?.find((org) => org.id === activeOrgId); + const orgInitial = activeOrg?.name?.charAt(0).toUpperCase() ?? "?"; + const otherOrgs = orgs?.filter((org) => org.id !== activeOrgId) ?? []; + + const handleSwitchOrg = async (orgId: string) => { + if (orgId === activeOrgId) return; + setSwitching(true); + try { + await authClient.organization.setActive({ organizationId: orgId }); + router.replace("/(authenticated)/(home)"); + } catch (error) { + console.error("[org/switch] Failed to switch organization:", error); + } finally { + setSwitching(false); + } + }; + + return ( + + + {/* Org section */} + + + Organization + + + + + + {orgInitial} + + + + {activeOrg?.name ?? "Select Organization"} + + + {otherOrgs.length > 0 && ( + <> + + {otherOrgs.map((org) => ( + handleSwitchOrg(org.id)} + disabled={switching} + className="flex-row items-center gap-3 px-4 py-3" + > + + + Switch to {org.name} + + + ))} + + )} + + + + {/* Menu items */} + + + General + + + router.push("/(authenticated)/(more)/settings")} + className="flex-row items-center gap-3 px-4 py-3" + > + + Settings + + + + + + {/* Sign out */} + + + + + Log out + + + + + + ); +} diff --git a/apps/mobile/screens/(authenticated)/(more)/index.ts b/apps/mobile/screens/(authenticated)/(more)/index.ts new file mode 100644 index 00000000000..6680f4454be --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(more)/index.ts @@ -0,0 +1 @@ +export { MoreMenuScreen } from "./MoreMenuScreen"; diff --git a/apps/mobile/screens/(authenticated)/(more)/settings/SettingsScreen.tsx b/apps/mobile/screens/(authenticated)/(more)/settings/SettingsScreen.tsx new file mode 100644 index 00000000000..0f0d688a1dd --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(more)/settings/SettingsScreen.tsx @@ -0,0 +1,61 @@ +import { useRouter } from "expo-router"; +import { ChevronLeft } from "lucide-react-native"; +import { Pressable, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Icon } from "@/components/ui/icon"; +import { Text } from "@/components/ui/text"; + +export function SettingsScreen() { + const router = useRouter(); + const insets = useSafeAreaInsets(); + + return ( + + + + router.back()} className="p-1"> + + + Settings + + + + + Account + + + + Account settings will appear here + + + + + + + Appearance + + + + Theme and display settings will appear here + + + + + + + Notifications + + + + Notification preferences will appear here + + + + + + ); +} diff --git a/apps/mobile/screens/(authenticated)/(more)/settings/index.ts b/apps/mobile/screens/(authenticated)/(more)/settings/index.ts new file mode 100644 index 00000000000..a76c924b112 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(more)/settings/index.ts @@ -0,0 +1 @@ +export { SettingsScreen } from "./SettingsScreen"; diff --git a/apps/mobile/screens/(authenticated)/(tasks)/tasks/TasksScreen.tsx b/apps/mobile/screens/(authenticated)/(tasks)/tasks/TasksScreen.tsx new file mode 100644 index 00000000000..27efda58021 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(tasks)/tasks/TasksScreen.tsx @@ -0,0 +1,30 @@ +import { useCallback, useState } from "react"; +import { RefreshControl, ScrollView, View } from "react-native"; +import { Text } from "@/components/ui/text"; + +export function TasksScreen() { + const [refreshing, setRefreshing] = useState(false); + + const onRefresh = useCallback(async () => { + setRefreshing(true); + // TODO: refresh task data + setRefreshing(false); + }, []); + + return ( + + } + > + + + + Tasks synced via Electric will appear here + + + + + ); +} diff --git a/apps/mobile/screens/(authenticated)/(tasks)/tasks/index.ts b/apps/mobile/screens/(authenticated)/(tasks)/tasks/index.ts new file mode 100644 index 00000000000..7c9a351cd92 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(tasks)/tasks/index.ts @@ -0,0 +1 @@ +export { TasksScreen } from "./TasksScreen"; diff --git a/apps/mobile/screens/(authenticated)/components/AuthenticatedTabBar/AuthenticatedTabBar.tsx b/apps/mobile/screens/(authenticated)/components/AuthenticatedTabBar/AuthenticatedTabBar.tsx new file mode 100644 index 00000000000..22fcb5c571a --- /dev/null +++ b/apps/mobile/screens/(authenticated)/components/AuthenticatedTabBar/AuthenticatedTabBar.tsx @@ -0,0 +1,101 @@ +import type { TabItem } from "@superset/tab-bar"; +import { TabBarView } from "@superset/tab-bar"; +import { useRouter } from "expo-router"; +import { useTabTrigger } from "expo-router/ui"; +import { useCallback, useRef, useState } from "react"; +import { StyleSheet, View } from "react-native"; +import { useOrganizations } from "@/screens/(authenticated)/hooks/useOrganizations"; + +const TABS: TabItem[] = [ + { name: "(home)", icon: "house.fill", label: "Home" }, + { name: "(tasks)", icon: "checkmark.square.fill", label: "Tasks" }, + { name: "__menu__", icon: "ellipsis", label: "More", isMenuTrigger: true }, +]; + +const NAVIGABLE_TAB_NAMES = ["(home)", "(tasks)"]; + +const MENU_ACTIONS = [ + { name: "views", icon: "square.stack", label: "Views" }, + { name: "customize", icon: "ellipsis", label: "Customize" }, +]; + +const COLLAPSE_ANIMATION_MS = 400; + +export function AuthenticatedTabBar() { + const router = useRouter(); + const { activeOrganization } = useOrganizations(); + const { switchTab, getTrigger } = useTabTrigger({ name: "(home)" }); + const [isExpanded, setIsExpanded] = useState(false); + const collapseTimer = useRef | null>(null); + + const activeTab = + NAVIGABLE_TAB_NAMES.find((name) => getTrigger(name)?.isFocused) ?? "(home)"; + + const handleExpandedChange = useCallback((expanded: boolean) => { + if (expanded) { + // Expand container immediately so SwiftUI has room to animate into + if (collapseTimer.current) { + clearTimeout(collapseTimer.current); + collapseTimer.current = null; + } + setIsExpanded(true); + } else { + // Delay container shrink so the SwiftUI close animation isn't clipped + collapseTimer.current = setTimeout(() => { + setIsExpanded(false); + collapseTimer.current = null; + }, COLLAPSE_ANIMATION_MS); + } + }, []); + + return ( + + { + switchTab(tab, { resetOnFocus: false }); + }} + onMenuActionPress={() => { + // placeholder — future navigation + }} + onSettingsPress={() => { + router.push("/(authenticated)/(more)/settings"); + }} + onSearchPress={() => { + // future + }} + onOrgPress={() => { + switchTab("(more)", { resetOnFocus: false }); + }} + onExpandedChange={handleExpandedChange} + /> + + ); +} + +const styles = StyleSheet.create({ + containerCollapsed: { + position: "absolute", + bottom: 0, + left: 0, + right: 0, + height: 96, + }, + containerExpanded: { + position: "absolute", + top: 0, + bottom: 0, + left: 0, + right: 0, + }, + tabBar: { + flex: 1, + }, +}); diff --git a/apps/mobile/screens/(authenticated)/components/AuthenticatedTabBar/index.ts b/apps/mobile/screens/(authenticated)/components/AuthenticatedTabBar/index.ts new file mode 100644 index 00000000000..6feaa1c6d20 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/components/AuthenticatedTabBar/index.ts @@ -0,0 +1 @@ +export { AuthenticatedTabBar } from "./AuthenticatedTabBar"; diff --git a/apps/mobile/screens/(authenticated)/components/OrgDropdown/OrgDropdown.tsx b/apps/mobile/screens/(authenticated)/components/OrgDropdown/OrgDropdown.tsx new file mode 100644 index 00000000000..fc1a9236af3 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/components/OrgDropdown/OrgDropdown.tsx @@ -0,0 +1,107 @@ +import { useLiveQuery } from "@tanstack/react-db"; +import { useRouter } from "expo-router"; +import { + ArrowLeftRight, + ChevronDown, + LogOut, + Settings, + UserPlus, +} from "lucide-react-native"; +import { useState } from "react"; +import { View } from "react-native"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Icon } from "@/components/ui/icon"; +import { Text } from "@/components/ui/text"; +import { useSignOut } from "@/hooks/useSignOut"; +import { authClient } from "@/lib/auth/client"; +import { useCollections } from "@/screens/(authenticated)/providers/CollectionsProvider"; + +export function OrgDropdown() { + const router = useRouter(); + const { signOut } = useSignOut(); + const collections = useCollections(); + const [switching, setSwitching] = useState(false); + + const session = authClient.useSession(); + const activeOrgId = session.data?.session?.activeOrganizationId; + + const { data: orgs } = useLiveQuery( + (q) => q.from({ organizations: collections.organizations }), + [collections], + ); + + const activeOrg = orgs?.find((org) => org.id === activeOrgId); + const orgInitial = activeOrg?.name?.charAt(0).toUpperCase() ?? "?"; + + const handleSwitchOrg = async (orgId: string) => { + if (orgId === activeOrgId) return; + setSwitching(true); + try { + await authClient.organization.setActive({ organizationId: orgId }); + router.replace("/(authenticated)/(home)"); + } catch (error) { + console.error("[org/switch] Failed to switch organization:", error); + } finally { + setSwitching(false); + } + }; + + return ( + + + + + + {orgInitial} + + + + + {activeOrg?.name ?? "Select Organization"} + + + + + + + router.push("/(authenticated)/settings")} + > + + Settings + + + + Invite members + + + {orgs + ?.filter((org) => org.id !== activeOrgId) + .map((org) => ( + handleSwitchOrg(org.id)} + disabled={switching} + > + + Switch to {org.name} + + ))} + {orgs && orgs.filter((org) => org.id !== activeOrgId).length > 0 && ( + + )} + + + Log out + + + + ); +} diff --git a/apps/mobile/screens/(authenticated)/components/OrgDropdown/index.ts b/apps/mobile/screens/(authenticated)/components/OrgDropdown/index.ts new file mode 100644 index 00000000000..19c7757cef8 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/components/OrgDropdown/index.ts @@ -0,0 +1 @@ +export { OrgDropdown } from "./OrgDropdown"; diff --git a/apps/mobile/screens/(authenticated)/components/TabBarAccessory/TabBarAccessory.tsx b/apps/mobile/screens/(authenticated)/components/TabBarAccessory/TabBarAccessory.tsx new file mode 100644 index 00000000000..9aba46c99c0 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/components/TabBarAccessory/TabBarAccessory.tsx @@ -0,0 +1,70 @@ +import Ionicons from "@expo/vector-icons/Ionicons"; +import { useRouter } from "expo-router"; +import { ChevronsUpDown } from "lucide-react-native"; +import { useState } from "react"; +import { Pressable, useWindowDimensions, View } from "react-native"; +import { Text } from "@/components/ui/text"; +import { useTheme } from "@/hooks/useTheme"; +import { OrganizationSwitcherSheet } from "@/screens/(authenticated)/(home)/workspaces/components/OrganizationSwitcherSheet"; +import { OrganizationAvatar } from "@/screens/(authenticated)/(home)/workspaces/components/OrganizationSwitcherSheet/components/OrganizationAvatar"; +import { useOrganizations } from "@/screens/(authenticated)/hooks/useOrganizations"; + +export function TabBarAccessory() { + const theme = useTheme(); + const router = useRouter(); + const { width } = useWindowDimensions(); + const [sheetOpen, setSheetOpen] = useState(false); + const { + organizations, + activeOrganization, + activeOrganizationId, + switchOrganization, + } = useOrganizations(); + + const handleSwitchOrganization = (organizationId: string) => { + setSheetOpen(false); + switchOrganization(organizationId); + }; + + return ( + <> + + setSheetOpen(true)} + className="flex-row items-center gap-2" + > + + + {activeOrganization?.name ?? "Organization"} + + + + router.push("/(authenticated)/(more)/settings")} + hitSlop={8} + > + + + + + + ); +} diff --git a/apps/mobile/screens/(authenticated)/components/TabBarAccessory/index.ts b/apps/mobile/screens/(authenticated)/components/TabBarAccessory/index.ts new file mode 100644 index 00000000000..e18d1582791 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/components/TabBarAccessory/index.ts @@ -0,0 +1 @@ +export { TabBarAccessory } from "./TabBarAccessory"; diff --git a/apps/mobile/screens/(authenticated)/demo/DemoScreen.tsx b/apps/mobile/screens/(authenticated)/demo/DemoScreen.tsx deleted file mode 100644 index 635d655031e..00000000000 --- a/apps/mobile/screens/(authenticated)/demo/DemoScreen.tsx +++ /dev/null @@ -1,924 +0,0 @@ -import { eq, isNull } from "@tanstack/db"; -import { useLiveQuery } from "@tanstack/react-db"; -import { - AlertCircle, - Bold, - ChevronRight, - Info, - Italic, - Mail, - Star, - Underline, - User, -} from "lucide-react-native"; -import * as React from "react"; -import { Pressable, ScrollView, View } from "react-native"; -import { OrganizationSwitcher } from "@/components/OrganizationSwitcher"; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@/components/ui/accordion"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { AspectRatio } from "@/components/ui/aspect-ratio"; -import { Avatar, AvatarFallback } from "@/components/ui/avatar"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Checkbox } from "@/components/ui/checkbox"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { Icon } from "@/components/ui/icon"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { Progress } from "@/components/ui/progress"; -import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Separator } from "@/components/ui/separator"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Switch } from "@/components/ui/switch"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Text } from "@/components/ui/text"; -import { Textarea } from "@/components/ui/textarea"; -import { Toggle, ToggleIcon } from "@/components/ui/toggle"; -import { - ToggleGroup, - ToggleGroupIcon, - ToggleGroupItem, -} from "@/components/ui/toggle-group"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { useCollections } from "@/providers/CollectionsProvider"; - -export function DemoScreen() { - const collections = useCollections(); - - // Live queries - const { data: organizations } = useLiveQuery( - (q) => q.from({ organizations: collections.organizations }), - [collections], - ); - - const { data: allTasks } = useLiveQuery( - (q) => q.from({ tasks: collections.tasks }), - [collections], - ); - - const { data: activeTasks } = useLiveQuery( - (q) => - q - .from({ tasks: collections.tasks }) - .where(({ tasks }) => isNull(tasks.deletedAt)), - [collections], - ); - - const { data: taskStatuses } = useLiveQuery( - (q) => q.from({ taskStatuses: collections.taskStatuses }), - [collections], - ); - - const { data: repositories } = useLiveQuery( - (q) => q.from({ repositories: collections.repositories }), - [collections], - ); - - const { data: members } = useLiveQuery( - (q) => q.from({ members: collections.members }), - [collections], - ); - - const { data: users } = useLiveQuery( - (q) => q.from({ users: collections.users }), - [collections], - ); - - const { data: invitations } = useLiveQuery( - (q) => q.from({ invitations: collections.invitations }), - [collections], - ); - - const { data: tasksWithStatus } = useLiveQuery( - (q) => - q - .from({ tasks: collections.tasks }) - .innerJoin({ status: collections.taskStatuses }, ({ tasks, status }) => - eq(tasks.statusId, status.id), - ) - .select(({ tasks, status }) => ({ - id: tasks.id, - title: tasks.title, - statusName: status.name, - statusColor: status.color, - })), - [collections], - ); - - // Component state - const [checkboxChecked, setCheckboxChecked] = React.useState(false); - const [switchChecked, setSwitchChecked] = React.useState(false); - const [radioValue, setRadioValue] = React.useState("option-1"); - const [selectValue, setSelectValue] = React.useState< - { value: string; label: string } | undefined - >(undefined); - const [progressValue, setProgressValue] = React.useState(33); - const [togglePressed, setTogglePressed] = React.useState(false); - const [toggleGroupValue, setToggleGroupValue] = React.useState([]); - const [collapsibleOpen, setCollapsibleOpen] = React.useState(false); - const [tabValue, setTabValue] = React.useState("tab1"); - const [inputValue, setInputValue] = React.useState(""); - const [textareaValue, setTextareaValue] = React.useState(""); - - return ( - - - {/* Header */} - - Component Demo - - All UI components + real-time synced data - - - - - - - - {/* ── Buttons ── */} - - - Button - All button variants and sizes - - - - - - - - - - - - - - - - - - - - {/* ── Badge ── */} - - - Badge - Status indicators - - - - - Default - - - Secondary - - - Destructive - - - Outline - - - - - - {/* ── Alert ── */} - - - Alert - Informational messages - - - - Heads up! - - This is a default alert component. - - - - Error - - Something went wrong. Please try again. - - - - - - {/* ── Avatar ── */} - - - Avatar - User profile images with fallback - - - - - - SP - - - - - KH - - - - - - - - - - - - {/* ── Input & Textarea ── */} - - - Input & Textarea - Text input fields - - - - - - - - -