# Project export: BananaPeel - Waste Sorting

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: CruzHacks 2026
- Tagline: BananaPeel helps you toss things in the right bin. Take a photo, get a clear compost/recycle/landfill answer based on local rules, plus a short “why.” Compete with other Peelies with the leaderboard!
- Devpost: https://devpost.com/software/bananapeel
- GitHub: https://github.com/lpeterson52/BananaPeel
- Demo: https://github.com/lpeterson52/RecyclingLeaderboard
- Video: https://www.youtube.com/embed/KMDH9g6tumI?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 3 GitHub contributor(s) — Alexander Hamilton (20 commits), skiidyop (11 commits), Lucas Peterson (9 commits)

## Devpost submission (written by the team)

### Inspiration

Inspired by Yuka, we wanted to make an app that was easy to pick up, to reduce the burden of information finding when trying to decide how to sort waste. To gamify it, we wanted to add a leaderboard similar to the one offered in Transit's GO system. This would keep user engagement and give them an extra reason to do an otherwise tedious task.

### What it does

BananaPeel uses a phone camera to take a photo of a piece of trash, and classifies it based on the material. It will then offer advice as to how to sort the waste, as well as store it in a history for easy recollection. There is also a leaderboard section, where users' device IDs are turned into anonymous playful names to compete with each other, without having to make an account. Yuka and Transit do this by default, and it's nice to be able to pick up an app and immediately be able to access all of its features.

### How we built it

Starting with React-native + Expo template, we modified the pre-existing tabs to feel our way through how we wanted to structure the app. Five pages eventually reduced to three, as we found ways to combine ideas like a Search and History tab into one to best fit the time constraint and end-goal of the project. A big part of the project was searching for an ample AI/ML model to process our images through, which we ended up using Roboflow's API to handle, removing the need for a computer-vision backend altogether. Processing can be done with a single API call, which lets us focus our resources on integrations within the app, while keeping the future flexible if we ever wish to train our own model. We built a system that can potentially use data from users to help improve future predictions for classification models. We then had extra time to build a leaderboard, which we used FastAPI, MongoDB, and Railway to implement a ranking system for users.

### Challenges we ran into

Getting an optimal model for the recognition Spent 4 hours building a backend for the vision, just to scratch it Unfamiliarity with Expo and React-native Live share in VSCode had major desync issues inference w/ roboflow (when trying to run locally) is extremely unintuitive and requires Python 3.12 or older, which conflicted with other dependencies npm, pnpm, yarn, bun... there's always a wrong option.

### Accomplishments we're proud of

Computer vision with Roboflow Microinteractions in UI Liquid glass implementation Searchable history First mobile app First time using MongoDB+FastAPI Transit-inspired anonymous leaderboard Very fast leaderboard fetching

### What we learned

Finding a suitable waste classification model proved much more difficult than initially anticipated. We should have started with a smaller scope-- we definitely didn't need a Discovery tab. It's OK to pivot ideas. The best idea is usually not the one you start with. Typescript doesn't always check types at runtime for cross-file function calls. There are slight differences in how style sheets are handled in HTML, React, and Expo. For example, Expo does not automatically balance font size and container height as HTML does-- you must manually add a lineHeight style modifier.

### What's next

for BananaPeel Bigger AI models, crowdsourcing training via the questionnaire feature, ability to customize profiles, and color themes! Overall, quicker classification. We want to expand the questionnaire feature and potentially add a feature where you can identify different objects within a taken image, training a new model. Upgrading from image classification to object detection would allow us to highlight multiple objects per image, offering more detailed and accurate results. This can be used to help high-tier recycling plants sort their waste, which would benefit people around the world instantly. One crowdsourced image for training by 1000 users can already outperform most waste-sorting models currently available.

## README (from the GitHub repository)

# BananaPeel
Mobile app to sort recycling based on photos. This repository contains the source code for the front end of the application.

## Inspiration
Inspired by Yuka, we wanted to make an app that was easy to pick up, to reduce the burden of information finding when trying to decide how to sort waste. To gamify it, we wanted to add a leaderboard similar to the one offered in Transit's GO system. This would keep user engagement and give them an extra reason to do an otherwise tedious task.

## What it does
BananaPeel uses a phone camera to take a photo of a piece of trash, and classifies it based on the material. It will then offer advice as to how to sort the waste, as well as store it in a history for easy recollection. There is also a leaderboard section, where users' device IDs are turned into anonymous playful names to compete with each other, without having to make an account. Yuka and Transit do this by default, and it's nice to be able to pick up an app and immediately be able to access all of its features.  

## How we built it
Starting with React-native + Expo template, we modified the pre-existing tabs to feel our way through how we wanted to structure the app. Five pages eventually reduced to three, as we found ways to combine ideas like a Search and History tab into one to best fit the time constraint and end-goal of the project. A big part of the project was searching for an ample AI/ML model to process our images through, which we ended up using Roboflow's API to handle, removing the need for a computer-vision backend altogether. Processing can be done with a single API call, which lets us focus our resources on integrations within the app, while keeping the future flexible if we ever wish to train our own model. We built a system that can potentially use data from users to help improve future predictions for classification models. We then had extra time to build a leaderboard, which we used FastAPI, MongoDB, and Railway to implement a ranking system for users. 

## Challenges we ran into
- Getting an optimal model for the recognition
- Spent 4 hours building a backend for the vision, just to scratch it
- Unfamiliarity with Expo and React-native
- Live share in VSCode had major desync issues
- inference w/ roboflow (when trying to run locally) is extremely unintuitive and requires Python 3.12 or older, which conflicted with other dependencies 
- npm, pnpm, yarn, bun... there's always a wrong option.

## Accomplishments that we're proud of
- Computer vision with Roboflow
- Microinteractions in UI
- Liquid glass implementation
- Searchable history
- First mobile app
- First time using MongoDB+FastAPI
- Transit-inspired anonymous leaderboard
- Very fast leaderboard fetching

## What we learned
- Finding a suitable waste classification model proved much more difficult than initially anticipated.
- We should have started with a smaller scope-- we definitely didn't need a Discovery tab.
- It's OK to pivot ideas. The best idea is usually *not* the one you start with.
- Typescript doesn't always check types at runtime for cross-file function calls.
- There are slight differences in how style sheets are handled in HTML, React, and Expo. For example, Expo does not automatically balance font size and container height as HTML does-- you must manually add a lineHeight style modifier.

## What's next for BananaPeel
Bigger AI models, crowdsourcing training via the questionnaire feature, ability to customize profiles, and color themes! Overall, quicker classification. We want to expand the questionnaire feature and potentially add a feature where you can identify different objects within a taken image, training a new model. Upgrading from image classification to object detection would allow us to highlight multiple objects per image, offering more detailed and accurate results. This can be used to help high-tier recycling plants sort their waste, which would benefit people around the world instantly. One crowdsourced image for training by 1000 users can already outperform most waste-sorting models currently available. 

## Detected evidence (automated analysis)

Indexed codebase: 33 recognized source files, 407 KB.
- JavaScript (language) — detected in the code
- React (technology) — detected in the code
- TypeScript (language) — detected in the code
- FastAPI (technology) — claimed on Devpost, not found in the code
- MongoDB (technology) — claimed on Devpost, not found in the code

## Codebase structure (from repository index)

### Files (37 of 37)

```
.expo/devices.json
.expo/README.md
.gitignore
app.json
app/_layout.tsx
app/(tabs)/_layout_old.tsx
app/(tabs)/_layout.tsx
app/(tabs)/history.tsx
app/(tabs)/index.tsx
app/(tabs)/leaderboard.tsx
app/modal.tsx
components/external-link.tsx
components/haptic-tab.tsx
components/hello-wave.tsx
components/information-modal.tsx
components/parallax-scroll-view.tsx
components/themed-text.tsx
components/themed-view.tsx
components/ui/collapsible.tsx
components/ui/icon-symbol.ios.tsx
components/ui/icon-symbol.tsx
constants/theme.ts
eslint.config.js
hooks/use-color-scheme.ts
hooks/use-color-scheme.web.ts
hooks/use-theme-color.ts
package.json
README.md
scripts/reset-project.js
tsconfig.json
util/deviceIdentifier.ts
util/historyStorage.ts
util/leaderboardData.ts
util/randomNames.ts
util/recycle-info.ts
util/robo2.ts
util/roboflow.ts
```

### Dependencies

- package.json: @expo/vector-icons@^15.0.3, @react-native-async-storage/async-storage@^2.2.0, @react-navigation/bottom-tabs@^7.4.0, @react-navigation/elements@^2.6.3, @react-navigation/native@^7.1.8, @rneui/base@4.0.0-rc.8, @rneui/themed@4.0.0-rc.8, @types/react@~19.1.0, axios@^1.13.2, babel-preset-expo@~54.0.9, eslint@^9.25.0, eslint-config-expo@~10.0.0, expo@>=54.0.0-0 <55.0.0, expo-application@~7.0.8, expo-camera@~17.0.10, expo-constants@~18.0.13, expo-file-system@~19.0.21, expo-font@~14.0.10, expo-glass-effect@~0.1.8, expo-haptics@~15.0.8, expo-image@~3.0.11, expo-image-manipulator@~14.0.8, expo-linking@~8.0.11, expo-router@~6.0.21, expo-splash-screen@~31.0.13, expo-status-bar@~3.0.9, expo-symbols@~1.0.8, expo-system-ui@~6.0.9, expo-web-browser@~15.0.10, react@19.1.0, react-dom@19.1.0, react-native@0.81.5, react-native-gesture-handler@~2.28.0, react-native-reanimated@~4.1.1, react-native-safe-area-context@~5.6.0, react-native-screens@~4.16.0, react-native-web@~0.21.0, react-native-worklets@0.5.1, typescript@~5.9.2

### Recent commits (newest first)

- add logos
- final changes
- feat: implement leaderboard functionality with user score tracking and device identifier
- fixed error in unknown detection, cleaned up unused function
- feat: refactor tab layout and enhance history screen with glass effect UI
- feat: improve layout and thumbnail styling in HistoryScreen
- feat: enhance history and recycling features with improved UI and functionality
- Stop tracking ignored file
- this is getting out of hand
- chore: update .gitignore to include expo stuff
- chore: add router.d.ts to .gitignore
- Merge pull request #8 from lpeterson52/alex
- Merge branch 'main' into alex
- Add router.d.ts to .gitignore
- fixed modal dragging
- feat: implement history tracking and UI for history screen
- Merge branch 'main' into alex
- refactor: 'implement' history tracking functionality
- combined results into scan tab
- add     "@react-native-async-storage/async-storage": "^2.2.0",

## Key source files (fetched from GitHub, selected and truncated for size)

### package.json

```
{
  "name": "bananapeel",
  "main": "expo-router/entry",
  "version": "1.0.0",
  "scripts": {
    "start": "expo start",
    "reset-project": "node ./scripts/reset-project.js",
    "android": "expo start --android",
    "ios": "expo start --ios",
    "web": "expo start --web",
    "lint": "expo lint"
  },
  "dependencies": {
    "@expo/vector-icons": "^15.0.3",
    "@react-native-async-storage/async-storage": "^2.2.0",
    "@react-navigation/bottom-tabs": "^7.4.0",
    "@react-navigation/elements": "^2.6.3",
    "@react-navigation/native": "^7.1.8",
    "@rneui/base": "4.0.0-rc.8",
    "@rneui/themed": "4.0.0-rc.8",
    "axios": "^1.13.2",
    "expo": ">=54.0.0-0 <55.0.0",
    "expo-application": "~7.0.8",
    "expo-camera": "~17.0.10",
    "expo-constants": "~18.0.13",
    "expo-file-system": "~19.0.21",
    "expo-font": "~14.0.10",
    "expo-glass-effect": "~0.1.8",
    "expo-haptics": "~15.0.8",
    "expo-image": "~3.0.11",
    "expo-image-manipulator": "~14.0.8",
    "expo-linking": "~8.0.11",
    "expo-router": "~6.0.21",
    "expo-splash-screen": "~31.0.13",
    "expo-status-bar": "~3.0.9",
    "expo-symbols": "~1.0.8",
    "expo-system-ui": "~6.0.9",
    "expo-web-browser": "~15.0.10",
    "react": "19.1.0",
    "react-dom": "19.1.0",
    "react-native": "0.81.5",
    "react-native-gesture-handler": "~2.28.0",
    "react-native-reanimated": "~4.1.1",
    "react-native-safe-area-context": "~5.6.0",
    "react-native-screens": "~4.16.0",
    "react-native-web": "~0.21.0",
    "react-native-worklets": "0.5.1"
  },
  "devDependencies": {
    "@types/react": "~19.1.0",
    "babel-preset-expo": "~54.0.9",
    "eslint": "^9.25.0",
    "eslint-config-expo": "~10.0.0",
    "typescript": "~5.9.2"
  },
  "private": true
}

```

### app/_layout.tsx

```typescript
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import 'react-native-reanimated';
import { GestureHandlerRootView } from 'react-native-gesture-handler';

import { useColorScheme } from '@/hooks/use-color-scheme';

export const unstable_settings = {
  anchor: '(tabs)',
};

export default function RootLayout() {
  const colorScheme = useColorScheme();

  return (
    <GestureHandlerRootView style={{ flex: 1 }}>
      <ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
        <Stack>
          <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
          <Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
        </Stack>
        <StatusBar style="auto" />
      </ThemeProvider>
    </GestureHandlerRootView>
  );
}

```

### app/(tabs)/_layout.tsx

```typescript
import { NativeTabs, Icon, Label } from 'expo-router/unstable-native-tabs';

export default function TabLayout() {
  return (
    <NativeTabs>
      <NativeTabs.Trigger name="history">
        <Label>History</Label>
        <Icon sf="clock" drawable="custom_android_drawable" />
      </NativeTabs.Trigger>
      <NativeTabs.Trigger name="index">
        <Label>Scan</Label>
        <Icon sf="camera" drawable="custom_android_drawable" />
      </NativeTabs.Trigger>
      <NativeTabs.Trigger name="leaderboard">
        <Label>Leaderboard</Label>
        <Icon sf="chart.bar" drawable="custom_settings_drawable" />
      </NativeTabs.Trigger>
    </NativeTabs>
  );
}

```

### app/(tabs)/index.tsx

```typescript
import { CameraView, useCameraPermissions } from "expo-camera";
import { Image } from "expo-image";
import { GlassView } from "expo-glass-effect";
import { useEffect, useRef, useState } from "react";
import { StyleSheet, Button, TouchableOpacity, View, ActivityIndicator, ScrollView } from "react-native";
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
import { SafeAreaView } from "react-native-safe-area-context";

import { ThemedText } from "@/components/themed-text";
import { ThemedView } from "@/components/themed-view";
import { InformationView, InformationSheetRef } from "@/components/information-modal";

import classifyImage from "../../util/roboflow";
import { saveHistoryItem, createThumbnail , getClassificationColor} from '../../util/historyStorage';

import { getRecycleInfo, isRecyclable , classifyRecyclability } from "@/util/recycle-info";

import { updateUserScore } from "@/util/leaderboardData";

function ResultSheetContent({
  imageUri,
  onLoaded,
}: {
  imageUri: string;
  onLoaded: () => void;
}) {
  const [loading, setLoading] = useState(true);
  const [result, setResult] = useState<any>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let cancelled = false;

    (async () => {
      try {
        setLoading(true);
        setError(null);
        const res = await classifyImage(imageUri);
        if (!cancelled) setResult(res);
        
        // Save to history after successful classification
        if (res?.predictions?.[0]) {
          try {
            const thumbnailUri = await createThumbnail(imageUri);
            const topPred = res.predictions[0];
            await saveHistoryItem({
              thumbnailUri,
              originalUri: imageUri,
              classification: classifyRecyclability(topPred.class),
              confidence: topPred.confidence ?? topPred.confidence_score,
              className: topPred.class,
            });
          } catch (saveError) {
            console.error("Failed to save to history:", saveError);
            // Don't block the UI if saving fails
          }
        }
      } catch (err: any) {
        if (!cancelled) setError(err?.message ?? String(err));
      } finally {
        if (!cancelled) {
          setLoading(false);
          onLoaded(); // tell sheet to expand to half
        }
      }
    })();

    return () => {
      cancelled = true;
    };
  }, [imageUri, onLoaded]);

  const topPrediction = result?.predictions?.[0] ?? null;

  const formatConfidenceValue = (v: any) => {
    const n = Number((v ?? 0)) * 100;
    return `${Number.isNaN(n) ? "0.0" : n.toFixed(1)}%`;
  };

  // const capitalize = (s?: string) => {
  //   if (!s) return "";
  //   return String(s)
  //     .toLowerCase()
  //     .split(/\s+/)
  //     .map((w) => (w ? w.charAt(0).toUpperCase() + w.slice(1) : ""))
  //     .join(" ");
  // };

  const [feedbackGiven, setFeedbackGiven] = useState(false);

  const handleFeedback = (isCorrect: boolean) => {
    // Handle feedback submission here
    setFeedbackGiven(true);
    updateUserScore(5);
  };

  return (
    <View style={{ flex: 1 }}>
      <View style={styles.sheetTitleRow}>
        <ThemedText style={styles.sheetTitle}>Result</ThemedText>
      </View>

      <View style={styles.sheetCard}>
        {loading ? (
          <View style={styles.centerRow}>
            <ActivityIndicator size="large" />
            <ThemedText>Classifying...</ThemedText>
          </View>
        ) : error ? (
          <ThemedText style={styles.error}>{error}</ThemedText>
        ) : (
          <ScrollView showsVerticalScrollIndicator={false}>
            <ThemedText
              type="subtitle"
              style={{
                color: topPrediction ? getClassificationColor(classifyRecyclability(topPrediction.class)) : 'gray',
                marginBottom: 10,
                fontSize: 30,
                textTransform: "capitalize"
              }}
            >
              {topPrediction ? classifyRecyclability(topPrediction.class) : "No detections"}
            </ThemedText>

            {topPrediction ? (
              <>
                <ThemedText type="subtitle">Prediction</ThemedText>
                <View style={styles.predictionRow}>
                  <ThemedText style={styles.predClass}>{topPrediction.class}</ThemedText>
                  <ThemedText>
                    {formatConfidenceValue(topPrediction.confidence ?? topPrediction.confidence_score)}
                  </ThemedText>
                </View>

                <View style={{ marginTop: 8 }}>
                  <ThemedText type="subtitle">Advice</ThemedText>
                  <ThemedText>{getRecycleInfo(topPrediction.class)}</ThemedText>
                </View>
              </>
            ) : (
              <ThemedText>Please try again</ThemedText>
            )}

            {result.predictions.length > 0 && (
              feedbackGiven ? <ThemedText style={{ marginTop: 40, textAlign: "center", color: "gray" }}>Thank you! Your feedback may help our predictions get better in the future.{"\n"}Here&apos;s 5 bonus points!</ThemedText> :
              <View style={styles.questionWrapper}>
                <ThemedText type="subtitle" style={styles.questions}>Is this prediction correct?</ThemedText>
                <View style={styles.feedbackButtonWrapper}>
                  <TouchableOpacity style={[styles.feedbackButton, {backgroundColor: 'rgba(0, 255, 0, 0.4)'}]} onPress={() => handleFeedback(true)}><ThemedText>Yes</ThemedText></TouchableOpacity>
                  <TouchableOpacity style={[styles.feedbackButton, {backgroundColor: 'rgba(255, 0, 0, 0.4)'}]} onPress={() => handleFeedback(false)}><ThemedText>No</ThemedText></TouchableOpacity>
                </View>
              </View>
            )}
          </ScrollView>
        )}
      </View>
    </View>
  );
}


export default function Scan() {
  const [permission, requestPermission] = useCameraPermissions();
[truncated — 6129 more characters]
```

### eslint.config.js

```javascript
// https://docs.expo.dev/guides/using-eslint/
const { defineConfig } = require('eslint/config');
const expoConfig = require('eslint-config-expo/flat');

module.exports = defineConfig([
  expoConfig,
  {
    ignores: ['dist/*'],
  },
]);

```

### hooks/use-color-scheme.ts

```typescript
export { useColorScheme } from 'react-native';

```

### components/hello-wave.tsx

```typescript
import Animated from 'react-native-reanimated';

export function HelloWave() {
  return (
    <Animated.Text
      style={{
        fontSize: 28,
        lineHeight: 32,
        marginTop: -6,
        animationName: {
          '50%': { transform: [{ rotate: '25deg' }] },
        },
        animationIterationCount: 4,
        animationDuration: '300ms',
      }}>
      👋
    </Animated.Text>
  );
}

```

### components/themed-view.tsx

```typescript
import { View, type ViewProps } from 'react-native';

import { useThemeColor } from '@/hooks/use-theme-color';

export type ThemedViewProps = ViewProps & {
  lightColor?: string;
  darkColor?: string;
};

export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
  const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');

  return <View style={[{ backgroundColor }, style]} {...otherProps} />;
}

```

### hooks/use-color-scheme.web.ts

```typescript
import { useEffect, useState } from 'react';
import { useColorScheme as useRNColorScheme } from 'react-native';

/**
 * To support static rendering, this value needs to be re-calculated on the client side for web
 */
export function useColorScheme() {
  const [hasHydrated, setHasHydrated] = useState(false);

  useEffect(() => {
    setHasHydrated(true);
  }, []);

  const colorScheme = useRNColorScheme();

  if (hasHydrated) {
    return colorScheme;
  }

  return 'light';
}

```

### hooks/use-theme-color.ts

```typescript
/**
 * Learn more about light and dark modes:
 * https://docs.expo.dev/guides/color-schemes/
 */

import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';

export function useThemeColor(
  props: { light?: string; dark?: string },
  colorName: keyof typeof Colors.light & keyof typeof Colors.dark
) {
  const theme = useColorScheme() ?? 'light';
  const colorFromProps = props[theme];

  if (colorFromProps) {
    return colorFromProps;
  } else {
    return Colors[theme][colorName];
  }
}

```

[21 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]