amnet-native initial commit

This commit is contained in:
ppc
2024-08-13 15:44:13 +01:00
parent d6f2cd18b8
commit b054bf4d4c
23 changed files with 15261 additions and 0 deletions
+130
View File
@@ -145,3 +145,133 @@ dist-ssr
*.sln
*.sw?
# Expo
.expo
__generated__
web-build
bare-apps
# macOS
.DS_Store
# Node
node_modules
npm-debug.log
yarn-error.log
# Ruby
.direnv
# Env
.envrc.local
# Emacs
*~
# Vim
.*.swp
.*.swo
.*.swn
.*.swm
# VS Code
.vscode/launch.json
# Sublime Text
*.sublime-project
*.sublime-workspace
# Xcode
*.pbxuser
!default.pbxuser
*.xccheckout
*.xcscmblueprint
xcuserdata
# IDEA / Android Studio
*.iml
.gradle
.idea
# Eclipse
.project
.settings
# VSCode
.history/
/vscode/launch.json
# Android
*.apk
*.hprof
ReactAndroid-temp.aar
# Tools
jarjar-rules.txt
# Dynamic Macros
.kernel-ngrok-url
# Template files
/apps/bare-expo/android/app/google-services.json
/apps/bare-expo/ios/BareExpo/GoogleService-Info.plist
# Template projects
templates/**/android/**/generated/*
templates/**/android/app/build
templates/**/Pods/**
templates/**/Podfile.lock
templates/**/yarn.lock
# Codemod
.codemod.bookmark
# Fastlane
/*.cer
/fastlane/report.xml
/fastlane/Preview.html
/fastlane/Deployment
/fastlane/test_output
/Preview.html
/gc_keys.json
/fastlane/gc_keys.json
# CI
/android/logcat.txt
# Shell apps
android-shell-app
shellAppBase-*
shellAppIntermediates
shellAppWorkspaces
/artifacts/*
# Expo Client builds
/client-builds
# Expo web env
.env.local
.env.development.local
.env.test.local
.env.production.local
apps/bare-expo/deploy-url.txt
# Temporary files created by Metro to check the health of the file watcher
.metro-health-check*
# Expo Doc merging
docs/pages/versions/*/react-native/ADDED_*.md
docs/pages/versions/*/react-native/REMOVED_*.md
docs/pages/versions/*/react-native/*.diff
# Expo Go
/apps/expo-go/src/dist
# Prebuilds
/packages/**/*.xcframework
/packages/**/*.spec.json
/packages/**/Info-generated.plist
!crsqlite.xcframework
# iOS
**/ios/.xcode.env.local
+75
View File
@@ -0,0 +1,75 @@
import React from "react";
import RealmContext from "./models/RealmContext";
import {AMUITabBar} from "./components/AMUITabBar";
import {Server, WalletCards} from "lucide-react-native";
import {NavigationContainer} from "@react-navigation/native";
import {SafeAreaProvider} from "react-native-safe-area-context";
import {createBottomTabNavigator} from "@react-navigation/bottom-tabs";
import {createNativeStackNavigator} from "@react-navigation/native-stack";
import {DarkTheme as NavigationDarkTheme} from '@react-navigation/native';
import {adaptNavigationTheme, MD3DarkTheme, MD3Theme, PaperProvider} from 'react-native-paper';
import CardAddModal from "./app/CardAddModal";
import ServerManager from "./app/ServerManager";
import CardManagerStack from "./app/CardManagerStack";
// blend themes to be a combination of react-native-paper and shadcn/ui
const AMDarkTheme: MD3Theme = {
...MD3DarkTheme,
roundness: 2,
colors: {
...MD3DarkTheme.colors,
primary: '#e11d48',
secondary: "#dc2626",
background: "#09090b",
surface: "#1c1917",
},
};
const {DarkTheme} = adaptNavigationTheme({
reactNavigationDark: NavigationDarkTheme,
materialDark: AMDarkTheme
});
const {RealmProvider} = RealmContext;
const Tab = createBottomTabNavigator();
const Stack = createNativeStackNavigator();
function AppContent() {
return (<Tab.Navigator tabBar={p => <AMUITabBar {...p}/>} screenOptions={{headerShown: false}}>
<Tab.Screen name="servers"
component={ServerManager}
options={{
title: 'Servers',
tabBarIcon: ({color, size}) => (<Server color={color} size={size}/>)
}}/>
<Tab.Screen name="cards"
component={CardManagerStack}
options={{
title: "Cards",
tabBarIcon: ({color, size}) => (<WalletCards color={color} size={size}/>)
}}/>
</Tab.Navigator>)
}
export default function AppRoot() {
return (
<RealmProvider>
<SafeAreaProvider>
<PaperProvider theme={AMDarkTheme}>
<NavigationContainer theme={DarkTheme}>
<Stack.Navigator initialRouteName="app-root">
{/* main app */}
<Stack.Screen name="app-root" component={AppContent} options={{headerShown: false}}/>
<Stack.Group screenOptions={{presentation: "modal"}}>
<Stack.Screen name="add-card" component={CardAddModal} options={{title: "Add Card"}}/>
</Stack.Group>
</Stack.Navigator>
</NavigationContainer>
</PaperProvider>
</SafeAreaProvider>
</RealmProvider>
)
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "AimeNet",
"slug": "amnet-native",
"icon": "./assets/icon.png",
"ios": {
"bundleIdentifier": "moe.ppc.amnet",
"infoPlist": {
"UIViewControllerBasedStatusBarAppearance": "YES"
}
},
"plugins": [
]
}
+119
View File
@@ -0,0 +1,119 @@
import {useCallback, useEffect, useMemo, useState} from "react";
import {Button, Card, Divider, Paragraph, useTheme} from "react-native-paper";
import {StyleSheet, TextInput, TouchableOpacity, View} from "react-native";
import RealmContext from "../models/RealmContext";
import {generateCardNumber} from "../models/AM-API";
import {AMCard} from "../models/AMCard";
const {useRealm} = RealmContext;
export default function CardAddModal({navigation}) {
const theme = useTheme();
const themedStylesheet = useMemo(() => StyleSheet.create({
...styles,
formItemInput: {
flex: 1,
color: theme.colors.onSurface
},
formFootnote: {
fontSize: 13,
},
headerText: {
color: theme.colors.primary
}
}), [theme]);
// state values
const realm = useRealm();
const [cardName, setCardName] = useState('');
const [cardNumber, setCardNumber] = useState('');
const cardAddCallback = useCallback((cardId: string, cardName: string) => {
const duplicateCard = realm.objects<AMCard>("card").find(x => x.code == cardNumber);
if (duplicateCard) {
realm.write(() => {
duplicateCard.name = cardName;
});
} else {
realm.write(() => {
realm.create("card", AMCard.generate(cardId, cardName));
});
}
navigation.navigate('card-listing');
}, [realm]);
// header effects
useEffect(() => {
navigation.setOptions({
headerLeft: () => <TouchableOpacity onPress={() => navigation.goBack()}>
<Paragraph style={themedStylesheet.headerText}>Cancel</Paragraph>
</TouchableOpacity>,
});
}, [navigation, themedStylesheet]);
useEffect(() => {
const canSave = cardName.length && cardNumber.length === 20;
const themeOptions = canSave ? themedStylesheet.headerText : {color: theme.colors.outline};
navigation.setOptions({
headerRight: () => <TouchableOpacity disabled={!canSave} onPress={() => cardAddCallback(cardNumber, cardName)}>
<Paragraph style={{...themeOptions, fontWeight: "600"}}>Save</Paragraph>
</TouchableOpacity>
})
}, [navigation, themedStylesheet, cardName, cardNumber]);
return (<View style={{display: "flex", gap: 10, justifyContent: "center", paddingHorizontal: 15, paddingTop: 25}}>
<Card>
<Card.Content>
<View style={themedStylesheet.formItemRow}>
<Paragraph style={themedStylesheet.formItemName}>
Card Name
</Paragraph>
<TextInput style={themedStylesheet.formItemInput}
numberOfLines={1} autoFocus
value={cardName}
onChangeText={setCardName}
placeholder="Primary Card"
placeholderTextColor={theme.colors.outline}/>
</View>
<Divider style={{marginVertical: 15}}/>
<View style={themedStylesheet.formItemRow}>
<Paragraph style={themedStylesheet.formItemName}>
Matrix Code
</Paragraph>
<TextInput style={themedStylesheet.formItemInput}
numberOfLines={1}
placeholder="0000 0000 0000 0000 0000"
placeholderTextColor={theme.colors.outline}
value={cardNumber.match(/.{1,4}/g)?.join(" ")}
onChangeText={val => setCardNumber(val?.replaceAll(/\D/g, '').substring(0, 20) ?? '')}/>
</View>
</Card.Content>
</Card>
<Paragraph style={themedStylesheet.formFootnote}>
If you have a physical card, you can enter the 20-digit code on the back.
</Paragraph>
<Button mode="text" onPress={() => setCardNumber(generateCardNumber())}>Generate Matrix Code</Button>
</View>)
}
const styles = StyleSheet.create({
formItemName: {
minWidth: 100,
fontWeight: "400"
},
formItemRow: {
flexDirection: "row",
alignItems: "center"
},
formItemDivider: {
marginVertical: 15
}
})
+53
View File
@@ -0,0 +1,53 @@
import * as Clipboard from 'expo-clipboard';
import {useCallback, useMemo} from "react";
import {FlatList, View} from "react-native";
import {AMCard} from "../models/AMCard";
import {AimeCard} from "../components/AimeCard";
import RealmContext from "../models/RealmContext";
import ContextMenu from "react-native-context-menu-view";
const {useQuery, useRealm} = RealmContext;
function CardEntry(props: { card: AMCard }) {
const realm = useRealm();
const callback = useCallback(async (index: number) => {
switch (index) {
// copy card no
case 0:
await Clipboard.setStringAsync(props.card.code);
break;
case 1:
realm.write(() => {
realm.delete(props.card);
});
break;
}
}, [realm, props.card]);
return (
<ContextMenu
onPress={e => callback(e.nativeEvent?.index)}
actions={[
{title: "Copy Card Number", systemIcon: "doc.on.doc"},
{title: "Delete Card", destructive: true, systemIcon: "trash"}]}>
<AimeCard card={props.card} showFullCardNumber/>
</ContextMenu>
);
}
export default function CardManager() {
const query = useQuery(AMCard);
const cards = useMemo(() => query.sorted("added", true), [query]);
return (
<FlatList contentInsetAdjustmentBehavior="always"
style={{padding: 16, paddingTop: 24}}
data={cards}
keyExtractor={i => i._id.toString()}
contentContainerStyle={{paddingBottom: 40}}
ItemSeparatorComponent={() => <View style={{height: 15}}/>}
renderItem={i => <CardEntry card={i.item}/>}/>
)
}
+29
View File
@@ -0,0 +1,29 @@
import {Plus} from "lucide-react-native";
import {useTheme} from "@react-navigation/native";
import {createNativeStackNavigator} from "@react-navigation/native-stack";
import CardManager from "./CardManager";
import {TouchableOpacity} from "react-native";
const Stack = createNativeStackNavigator();
export default function CardManagerStack({navigation}) {
const theme = useTheme();
return (
<Stack.Navigator screenOptions={{
headerLargeTitle: true,
headerBlurEffect: "regular",
headerStyle: {
backgroundColor: theme.colors.background,
}
}}>
<Stack.Screen name="card-listing"
component={CardManager}
options={{
title: "Cards",
headerRight: () => <TouchableOpacity onPress={() => navigation.navigate('add-card')}><Plus/></TouchableOpacity>
}}/>
</Stack.Navigator>
);
}
+9
View File
@@ -0,0 +1,9 @@
import {Text, View} from "react-native";
export default function ServerManager() {
return (
<View>
<Text>Server Manager</Text>
</View>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

+6
View File
@@ -0,0 +1,6 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
};
};
+64
View File
@@ -0,0 +1,64 @@
import React from "react";
import {TouchableOpacity, View} from "react-native";
import {Card, Title, useTheme} from "react-native-paper";
export interface AMUICardActionProps {
actionIcon?: Readonly<React.ReactNode>,
actionDisabled?: boolean,
onPress?: () => void,
onLongPress?: () => void,
}
export interface AMUICardProps {
title: string,
children?: Readonly<React.ReactNode>
}
export function AMUICard(props: AMUICardActionProps & AMUICardProps) {
if (props.actionDisabled == undefined && !props.onPress && !props.onLongPress) {
return (<CardComponent {...props}/>)
}
return (
<TouchableOpacity disabled={props.actionDisabled} onPress={props.onPress} onLongPress={props.onLongPress}>
<CardComponent {...props}/>
</TouchableOpacity>
);
}
export function AMUICardDetail(props: { children?: Readonly<React.ReactNode> }) {
return (
<View style={{display: "flex", flexDirection: "row", alignItems: "center", gap: 5}}>
{props.children}
</View>
)
}
function CardComponent(props: AMUICardProps & AMUICardActionProps) {
const theme = useTheme();
return (<Card mode="outlined" style={{backgroundColor: theme.colors.background, borderColor: theme.colors.surface}}>
<Card.Content style={{
flexDirection: "row",
alignItems: "center",
display: "flex",
flexShrink: 1,
flexGrow: 1,
gap: 5
}}>
<View>
<Title style={{fontWeight: "bold"}}>{props.title}</Title>
<View style={{display: "flex", flexDirection: "row", gap: 8, flexWrap: "wrap", marginTop: 10}}>
{props.children}
</View>
</View>
{props.actionIcon && (
<View style={{flexShrink: 0}}>
{props.actionIcon}
</View>
)}
</Card.Content>
</Card>
)
}
+48
View File
@@ -0,0 +1,48 @@
import {useTheme} from 'react-native-paper';
import {TouchableOpacity, View} from "react-native";
// tab bar inspired by https://stackoverflow.com/a/64028171
export function AMUITabBar({state, descriptors, navigation}) {
const theme = useTheme();
return (
<View
style={{
left: 0,
right: 0,
bottom: 0,
height: 60,
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "space-around",
backgroundColor: theme.colors.surface,
}}>
{state.routes.map((route, index) => {
const {options} = descriptors[route.key];
const isFocused = state.index === index;
const onPress = () => {
const event = navigation.emit({
type: 'tabPress',
target: route.key,
});
if (!isFocused && !event.defaultPrevented) {
navigation.navigate(route.name);
}
};
return (
<TouchableOpacity
key={route.key}
accessibilityRole="button"
accessibilityLabel={options.tabBarAccessibilityLabel}
testID={options.tabBarTestID}
onPress={onPress}>
{options.tabBarIcon({color: isFocused ? theme.colors.primary : theme.colors.outline, size: 24})}
</TouchableOpacity>
);
})}
</View>
);
}
+34
View File
@@ -0,0 +1,34 @@
import {useMemo} from "react";
import {Paragraph} from "react-native-paper";
import {CreditCard, History} from "lucide-react-native";
import TimeAgo from "./TimeAgo";
import {AMCardData} from "../models/AMCard";
import {AMUICard, AMUICardActionProps, AMUICardDetail} from "./AMUICard";
export interface AimeCardProps extends AMUICardActionProps {
card: AMCardData,
showFullCardNumber?: boolean
}
export function AimeCard(props: AimeCardProps) {
const displayedCardNumber = useMemo(() => props.showFullCardNumber
? props.card.code.match(/.{1,4}/g)?.join(" ")
: `•••• ${props.card.code.substring(props.card.code.length - 4)}`,
[props.showFullCardNumber, props.card.code]);
return (
<AMUICard title={props.card.name} {...props}>
<AMUICardDetail>
<CreditCard size={18} color="gray"/>
<Paragraph style={{color: "gray"}}>{displayedCardNumber}</Paragraph>
</AMUICardDetail>
{props.card.lastUsed && (
<AMUICardDetail>
<History size={18} color="gray"/>
<TimeAgo style={{color: "gray"}} dateTo={props.card.lastUsed}/>
</AMUICardDetail>
)}
</AMUICard>
);
}
+39
View File
@@ -0,0 +1,39 @@
import React, {useEffect, useState} from 'react';
import {StyleProp, TextStyle} from 'react-native';
import {formatDistance} from 'date-fns/formatDistance';
import {Paragraph} from "react-native-paper";
interface Props {
dateTo: Date;
updateInterval?: number;
dateFrom?: Date;
hideAgo?: boolean;
style?: StyleProp<TextStyle>;
}
const TimeAgo: React.FC<Props> = ({
dateTo,
dateFrom,
updateInterval = 60000,
hideAgo = false,
style,
}) => {
const [currentDate, setCurrentDate] = useState(new Date());
useEffect(() => {
const interval = setInterval(() => {
if (!dateFrom) setCurrentDate(new Date());
}, updateInterval);
return () => clearInterval(interval);
}, [dateFrom, updateInterval]);
return (
<Paragraph {...{style}}>
{formatDistance(dateTo, dateFrom || currentDate, {
addSuffix: !hideAgo,
})}
</Paragraph>
);
};
export default TimeAgo;
+18
View File
@@ -0,0 +1,18 @@
{
"cli": {
"version": ">= 0.42.4"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal"
},
"production": {}
},
"submit": {
"production": {}
}
}
+11
View File
@@ -0,0 +1,11 @@
import 'expo-dev-client';
import 'react-native-get-random-values';
import {registerRootComponent} from "expo";
import App from "./App";
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in the Expo client or in a native build,
// the environment is set up appropriately
registerRootComponent(App);
+4
View File
@@ -0,0 +1,4 @@
// Learn more https://docs.expo.dev/guides/customizing-metro
const { getDefaultConfig } = require('expo/metro-config');
module.exports = getDefaultConfig(__dirname);
+28
View File
@@ -0,0 +1,28 @@
export interface AMServerInfoResponse {
server_name: string;
api_version: number;
}
export interface AMCardInRequest {
card_id: string;
}
export function generateCardNumber(): string {
let cardId = '';
while (true) {
// don't start with a 3
const firstDigit = Math.floor(Math.random() * 10);
if (firstDigit !== 3) {
cardId = firstDigit.toString();
break;
}
}
for (let i = 0; i < 19; i++) {
cardId += Math.floor(Math.random() * 10).toString();
}
return cardId;
}
+41
View File
@@ -0,0 +1,41 @@
import {Realm} from "realm";
export interface AMCardData {
code: string;
name: string;
added: Date;
lastUsed?: Date | null;
}
export class AMCard extends Realm.Object<AMCard> implements AMCardData {
_id!: Realm.BSON.ObjectId;
code: string;
name: string;
added: Date;
lastUsed?: Date | null;
static generate(code: string, name: string) {
return {
_id: new Realm.BSON.ObjectId(),
code: code,
name: name,
added: new Date()
};
}
static schema: Realm.ObjectSchema = {
name: "card",
primaryKey: "_id",
properties: {
_id: "objectId",
code: "string",
name: {type: "string", default: "New Card"},
added: {type: "date", default: new Date()},
lastUsed: {type: "date", optional: true}
}
};
}
+63
View File
@@ -0,0 +1,63 @@
import {Realm} from 'realm';
import {AMServerInfoResponse} from "./AM-API";
export class AMServer extends Realm.Object<AMServer> implements AMServerInfoResponse {
_id!: Realm.BSON.ObjectId;
server_name: string;
server_address: string;
last_used: Date;
created_at: Date;
api_version: number;
async connect(abort?: AbortSignal | undefined): Promise<boolean> {
try {
const response = await fetch(this.server_address, {
signal: abort
});
const data: AMServerInfoResponse = await response.json();
this.last_used = new Date();
Object.apply(this, data);
return true;
} catch {
return false;
}
}
async submitCard(cardId: string, abort?: AbortSignal | undefined): Promise<boolean> {
try {
const response = await fetch(`${this.server_address}/card`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
card_id: cardId
}),
signal: abort
});
return response.ok;
} catch {
return false;
}
}
static schema: Realm.ObjectSchema = {
name: "server",
primaryKey: "_id",
properties: {
_id: "objectId",
server_address: "string",
server_name: {type: "string", default: "New Server"},
last_used: {type: "date", optional: true},
created_at: {type: "date", default: new Date()},
api_version: {type: "int", default: 0}
}
};
}
+11
View File
@@ -0,0 +1,11 @@
import {createRealmContext} from "@realm/react";
import {AMCard} from "./AMCard";
import {AMServer} from "./AMServer";
// @ts-ignore
export default createRealmContext({
inMemory: true,
schemaVersion: 1,
schema: [AMCard, AMServer],
deleteRealmIfMigrationNeeded: true
});
+14424
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
{
"name": "amnet-native",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "expo start --dev-client",
"android": "expo run:android",
"ios": "expo run:ios"
},
"dependencies": {
"@react-navigation/bottom-tabs": "^6.6.1",
"@react-navigation/native": "^6.1.18",
"@react-navigation/native-stack": "^6.11.0",
"@realm/react": "^0.9.0",
"@types/react": "~18.2.79",
"date-fns": "^3.6.0",
"expo": "^51.0.0",
"expo-blur": "~13.0.2",
"expo-constants": "~16.0.2",
"expo-dev-client": "~4.0.10",
"expo-linking": "~6.3.1",
"expo-status-bar": "~1.12.1",
"lucide-react-native": "^0.427.0",
"react": "18.2.0",
"react-native": "0.74.1",
"react-native-context-menu-view": "^1.16.0",
"react-native-gesture-handler": "^2.18.1",
"react-native-get-random-values": "^1.11.0",
"react-native-paper": "^5.12.5",
"react-native-reanimated": "^3.15.0",
"react-native-safe-area-context": "4.10.5",
"react-native-screens": "3.31.1",
"react-native-svg": "^15.5.0",
"realm": "^12.12.1",
"typescript": "~5.3.3",
"expo-clipboard": "~6.0.3"
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"compilerOptions": {},
"extends": "expo/tsconfig.base"
}