ci: add iOS OTA publishing and refactor gesture handling
- Add `ota-ios` job to GitHub Actions workflow to support iOS OTA updates - Refactor `ScheduleScreen` to use `GestureDetector` and `Gesture` from `react-native-gesture-handler` instead of `PanGestureHandler` - Update `TradeCard` styles and layout spacing in `HomeScreen` and `MarketView` - Refactor `MarketView` to use updated responsive spacing values
This commit is contained in:
@@ -21,7 +21,8 @@ on:
|
|||||||
env:
|
env:
|
||||||
REGISTRY: code.littlelan.cn
|
REGISTRY: code.littlelan.cn
|
||||||
IMAGE_NAME: carrot_bbs/frontend-web
|
IMAGE_NAME: carrot_bbs/frontend-web
|
||||||
OTA_PLATFORM: android
|
OTA_PLATFORM_ANDROID: android
|
||||||
|
OTA_PLATFORM_IOS: ios
|
||||||
OTA_PUBLISH_URL: https://updates.littlelan.cn/admin/publish
|
OTA_PUBLISH_URL: https://updates.littlelan.cn/admin/publish
|
||||||
OTA_MANIFEST_URL: https://updates.littlelan.cn/api/manifest
|
OTA_MANIFEST_URL: https://updates.littlelan.cn/api/manifest
|
||||||
|
|
||||||
@@ -82,7 +83,7 @@ jobs:
|
|||||||
OTA_AUTH_TOKEN: ${{ secrets.OTA_AUTH_TOKEN }}
|
OTA_AUTH_TOKEN: ${{ secrets.OTA_AUTH_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
test -n "${OTA_AUTH_TOKEN}" || (echo "Missing secret OTA_AUTH_TOKEN" && exit 1)
|
test -n "${OTA_AUTH_TOKEN}" || (echo "Missing secret OTA_AUTH_TOKEN" && exit 1)
|
||||||
curl -fSs -X POST "${OTA_PUBLISH_URL}?runtimeVersion=${{ steps.runtime.outputs.runtime_version }}&platform=${OTA_PLATFORM}" \
|
curl -fSs -X POST "${OTA_PUBLISH_URL}?runtimeVersion=${{ steps.runtime.outputs.runtime_version }}&platform=${OTA_PLATFORM_ANDROID}" \
|
||||||
-H "Authorization: Bearer ${OTA_AUTH_TOKEN}" \
|
-H "Authorization: Bearer ${OTA_AUTH_TOKEN}" \
|
||||||
-H "Content-Type: application/zip" \
|
-H "Content-Type: application/zip" \
|
||||||
--data-binary @"dist-android-update.zip"
|
--data-binary @"dist-android-update.zip"
|
||||||
@@ -90,7 +91,7 @@ jobs:
|
|||||||
- name: Verify OTA manifest
|
- name: Verify OTA manifest
|
||||||
run: |
|
run: |
|
||||||
REMOTE="$(curl -sS "${OTA_MANIFEST_URL}" \
|
REMOTE="$(curl -sS "${OTA_MANIFEST_URL}" \
|
||||||
-H "expo-platform: ${OTA_PLATFORM}" \
|
-H "expo-platform: ${OTA_PLATFORM_ANDROID}" \
|
||||||
-H "expo-runtime-version: ${{ steps.runtime.outputs.runtime_version }}" \
|
-H "expo-runtime-version: ${{ steps.runtime.outputs.runtime_version }}" \
|
||||||
-H "expo-protocol-version: 1" \
|
-H "expo-protocol-version: 1" \
|
||||||
| python -c "import sys, json, re; s=sys.stdin.read(); m=re.search(r'\\{\\\"id\\\":.*\\\"extra\\\":\\{.*\\}\\}', s); j=json.loads(m.group(0)); print(j['launchAsset']['url'].split('/')[-1].split('&')[0])")"
|
| python -c "import sys, json, re; s=sys.stdin.read(); m=re.search(r'\\{\\\"id\\\":.*\\\"extra\\\":\\{.*\\}\\}', s); j=json.loads(m.group(0)); print(j['launchAsset']['url'].split('/')[-1].split('&')[0])")"
|
||||||
@@ -99,6 +100,79 @@ jobs:
|
|||||||
echo "Local bundle: ${LOCAL}"
|
echo "Local bundle: ${LOCAL}"
|
||||||
test "${REMOTE}" = "${LOCAL}"
|
test "${REMOTE}" = "${LOCAL}"
|
||||||
|
|
||||||
|
ota-ios:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name != 'pull_request' && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish_ota == 'true')
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '25.6.1'
|
||||||
|
registry-url: 'https://registry.npmmirror.com'
|
||||||
|
|
||||||
|
- name: Remove deprecated always-auth npm config
|
||||||
|
run: |
|
||||||
|
if [ -f ~/.npmrc ]; then
|
||||||
|
sed -i '/always-auth/d' ~/.npmrc
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Resolve runtime version
|
||||||
|
id: runtime
|
||||||
|
run: |
|
||||||
|
RUNTIME_VERSION="$(node -p "require('./app.json').expo.version")"
|
||||||
|
echo "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Resolved runtimeVersion: ${RUNTIME_VERSION}"
|
||||||
|
|
||||||
|
- name: Export iOS update bundle
|
||||||
|
run: |
|
||||||
|
rm -rf dist-ios dist-ios-update.zip
|
||||||
|
npx expo export --platform ios --output-dir dist-ios
|
||||||
|
npx expo config --type public --json > dist-ios/expoConfig.json
|
||||||
|
|
||||||
|
- name: Archive iOS update bundle
|
||||||
|
run: |
|
||||||
|
python - <<'PY'
|
||||||
|
import os
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
with zipfile.ZipFile('dist-ios-update.zip', 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for root, _, files in os.walk('dist-ios'):
|
||||||
|
for name in files:
|
||||||
|
src = os.path.join(root, name)
|
||||||
|
arc = os.path.relpath(src, 'dist-ios')
|
||||||
|
zf.write(src, arc)
|
||||||
|
PY
|
||||||
|
|
||||||
|
- name: Publish OTA
|
||||||
|
env:
|
||||||
|
OTA_AUTH_TOKEN: ${{ secrets.OTA_AUTH_TOKEN }}
|
||||||
|
run: |
|
||||||
|
test -n "${OTA_AUTH_TOKEN}" || (echo "Missing secret OTA_AUTH_TOKEN" && exit 1)
|
||||||
|
curl -fSs -X POST "${OTA_PUBLISH_URL}?runtimeVersion=${{ steps.runtime.outputs.runtime_version }}&platform=${OTA_PLATFORM_IOS}" \
|
||||||
|
-H "Authorization: Bearer ${OTA_AUTH_TOKEN}" \
|
||||||
|
-H "Content-Type: application/zip" \
|
||||||
|
--data-binary @"dist-ios-update.zip"
|
||||||
|
|
||||||
|
- name: Verify OTA manifest
|
||||||
|
run: |
|
||||||
|
REMOTE="$(curl -sS "${OTA_MANIFEST_URL}" \
|
||||||
|
-H "expo-platform: ${OTA_PLATFORM_IOS}" \
|
||||||
|
-H "expo-runtime-version: ${{ steps.runtime.outputs.runtime_version }}" \
|
||||||
|
-H "expo-protocol-version: 1" \
|
||||||
|
| python -c "import sys, json, re; s=sys.stdin.read(); m=re.search(r'\\{\"id\":.*\"extra\":\{.*\}\}', s); j=json.loads(m.group(0)); print(j['launchAsset']['url'].split('/')[-1].split('&')[0])")"
|
||||||
|
LOCAL="$(python -c "import json; m=json.load(open('dist-ios/metadata.json')); print(m['fileMetadata']['ios']['bundle'].split('/')[-1])")"
|
||||||
|
echo "Remote bundle: ${REMOTE}"
|
||||||
|
echo "Local bundle: ${LOCAL}"
|
||||||
|
test "${REMOTE}" = "${LOCAL}"
|
||||||
|
|
||||||
build-android-apk:
|
build-android-apk:
|
||||||
runs-on: android-builder
|
runs-on: android-builder
|
||||||
container:
|
container:
|
||||||
|
|||||||
@@ -22,14 +22,13 @@ function createTradeCardStyles(colors: AppColors) {
|
|||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
gridCard: {
|
gridCard: {
|
||||||
backgroundColor: colors.background.paper,
|
backgroundColor: colors.background.paper,
|
||||||
borderRadius: borderRadius.xl,
|
borderRadius: borderRadius.lg,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
// subtle shadow for depth
|
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOffset: { width: 0, height: 1 },
|
shadowOffset: { width: 0, height: 2 },
|
||||||
shadowOpacity: 0.06,
|
shadowOpacity: 0.08,
|
||||||
shadowRadius: 3,
|
shadowRadius: 6,
|
||||||
elevation: 2,
|
elevation: 3,
|
||||||
},
|
},
|
||||||
listCard: {
|
listCard: {
|
||||||
backgroundColor: colors.background.paper,
|
backgroundColor: colors.background.paper,
|
||||||
|
|||||||
@@ -796,7 +796,7 @@ export const HomeScreen: React.FC = () => {
|
|||||||
const authorId = item.author?.id || '';
|
const authorId = item.author?.id || '';
|
||||||
const isPostAuthor = currentUser?.id === authorId;
|
const isPostAuthor = currentUser?.id === authorId;
|
||||||
return (
|
return (
|
||||||
<View style={styles.gridItem}>
|
<View style={{ marginBottom: 2, paddingHorizontal: 1 }}>
|
||||||
<PostCard
|
<PostCard
|
||||||
post={item}
|
post={item}
|
||||||
variant="grid"
|
variant="grid"
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ export function MarketView({
|
|||||||
const isAuth = useIsAuthenticated();
|
const isAuth = useIsAuthenticated();
|
||||||
const { width } = useResponsive();
|
const { width } = useResponsive();
|
||||||
|
|
||||||
const gap = useResponsiveSpacing({ xs: 6, sm: 8, md: 10, lg: 12, xl: 16 });
|
const gap = useResponsiveSpacing({ xs: 4, sm: 6, md: 8, lg: 12, xl: 16 });
|
||||||
const padding = useResponsiveSpacing({ xs: 8, sm: 10, md: 12, lg: 16, xl: 20 });
|
const padding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 });
|
||||||
const styles = useMemo(() => createMarketStyles(colors, gap, padding), [colors, gap, padding]);
|
const styles = useMemo(() => createMarketStyles(colors, gap, padding), [colors, gap, padding]);
|
||||||
|
|
||||||
const [items, setItems] = useState<TradeItemDTO[]>([]);
|
const [items, setItems] = useState<TradeItemDTO[]>([]);
|
||||||
@@ -163,7 +163,7 @@ export function MarketView({
|
|||||||
}, [items, isAuth]);
|
}, [items, isAuth]);
|
||||||
|
|
||||||
const renderItem = useCallback<ListRenderItem<TradeItemDTO>>(({ item }) => (
|
const renderItem = useCallback<ListRenderItem<TradeItemDTO>>(({ item }) => (
|
||||||
<View style={{ marginBottom: gap }}>
|
<View style={{ marginBottom: 2, paddingHorizontal: 1 }}>
|
||||||
<TradeCard
|
<TradeCard
|
||||||
item={item}
|
item={item}
|
||||||
variant="grid"
|
variant="grid"
|
||||||
@@ -171,7 +171,7 @@ export function MarketView({
|
|||||||
onFavorite={handleFavorite}
|
onFavorite={handleFavorite}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
), [handlePressItem, handleFavorite, gap]);
|
), [handlePressItem, handleFavorite]);
|
||||||
|
|
||||||
const keyExtractor = useCallback((item: TradeItemDTO) => item.id, []);
|
const keyExtractor = useCallback((item: TradeItemDTO) => item.id, []);
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import {
|
|||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Platform,
|
Platform,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { PanGestureHandler, State } from 'react-native-gesture-handler';
|
import { GestureDetector, Gesture } from 'react-native-gesture-handler';
|
||||||
import type { PanGestureHandlerStateChangeEvent } from 'react-native-gesture-handler';
|
import { runOnJS } from 'react-native-reanimated';
|
||||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { blurActiveElement } from '../../infrastructure/platform';
|
import { blurActiveElement } from '../../infrastructure/platform';
|
||||||
@@ -305,20 +305,23 @@ export const ScheduleScreen: React.FC = () => {
|
|||||||
[courses]
|
[courses]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleWeekSwipe = useCallback(
|
const goNextWeek = useCallback(() => setCurrentWeek(prev => Math.min(prev + 1, weeks.length)), [weeks.length]);
|
||||||
({ nativeEvent }: PanGestureHandlerStateChangeEvent) => {
|
const goPrevWeek = useCallback(() => setCurrentWeek(prev => Math.max(prev - 1, 1)), [weeks.length]);
|
||||||
if (nativeEvent.oldState !== State.ACTIVE && nativeEvent.oldState !== State.BEGAN) return;
|
|
||||||
const { translationX, translationY } = nativeEvent;
|
|
||||||
if (Math.abs(translationX) < SWIPE_THRESHOLD) return;
|
|
||||||
if (Math.abs(translationX) <= Math.abs(translationY) * 0.8) return;
|
|
||||||
|
|
||||||
if (translationX < 0) {
|
const weekSwipeGesture = useMemo(() =>
|
||||||
setCurrentWeek(prev => Math.min(prev + 1, weeks.length));
|
Gesture.Pan()
|
||||||
} else {
|
.activeOffsetX([-8, 8])
|
||||||
setCurrentWeek(prev => Math.max(prev - 1, 1));
|
.failOffsetY([-80, 80])
|
||||||
}
|
.onEnd((e) => {
|
||||||
},
|
if (Math.abs(e.translationX) < SWIPE_THRESHOLD) return;
|
||||||
[weeks.length]
|
if (Math.abs(e.translationX) <= Math.abs(e.translationY) * 0.8) return;
|
||||||
|
if (e.translationX < 0) {
|
||||||
|
runOnJS(goNextWeek)();
|
||||||
|
} else {
|
||||||
|
runOnJS(goPrevWeek)();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[goNextWeek, goPrevWeek]
|
||||||
);
|
);
|
||||||
|
|
||||||
const webTouchStartRef = useRef<{ x: number; y: number; time: number } | null>(null);
|
const webTouchStartRef = useRef<{ x: number; y: number; time: number } | null>(null);
|
||||||
@@ -797,11 +800,7 @@ export const ScheduleScreen: React.FC = () => {
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<PanGestureHandler
|
<GestureDetector gesture={weekSwipeGesture}>
|
||||||
activeOffsetX={[-8, 8]}
|
|
||||||
failOffsetY={[-80, 80]}
|
|
||||||
onHandlerStateChange={handleWeekSwipe}
|
|
||||||
>
|
|
||||||
<View style={styles.scheduleBodyGestureLayer}>
|
<View style={styles.scheduleBodyGestureLayer}>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.scheduleBody}
|
style={styles.scheduleBody}
|
||||||
@@ -809,21 +808,14 @@ export const ScheduleScreen: React.FC = () => {
|
|||||||
contentContainerStyle={styles.scheduleBodyContent}
|
contentContainerStyle={styles.scheduleBodyContent}
|
||||||
>
|
>
|
||||||
{renderTimeColumn()}
|
{renderTimeColumn()}
|
||||||
<ScrollView
|
<View style={styles.daysContainer}>
|
||||||
horizontal
|
{VISIBLE_DAY_VALUES.map((dayOfWeek, index) =>
|
||||||
showsHorizontalScrollIndicator={false}
|
renderDayColumn(dayOfWeek, index, index === VISIBLE_DAY_VALUES.length - 1)
|
||||||
nestedScrollEnabled
|
)}
|
||||||
contentContainerStyle={{ flexGrow: 1 }}
|
</View>
|
||||||
>
|
|
||||||
<View style={styles.daysContainer}>
|
|
||||||
{VISIBLE_DAY_VALUES.map((dayOfWeek, index) =>
|
|
||||||
renderDayColumn(dayOfWeek, index, index === VISIBLE_DAY_VALUES.length - 1)
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
</PanGestureHandler>
|
</GestureDetector>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user