ci: add iOS OTA publishing and refactor gesture handling
Some checks failed
Frontend CI / ota-android (push) Successful in 1m10s
Frontend CI / ota-ios (push) Successful in 1m8s
Frontend CI / build-and-push-web (push) Successful in 6m1s
Frontend CI / build-android-apk (push) Has been cancelled

- 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:
2026-05-05 17:46:31 +08:00
parent c0ebebe3da
commit f5f9c3a619
5 changed files with 112 additions and 47 deletions

View File

@@ -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:

View File

@@ -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,

View File

@@ -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"

View File

@@ -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, []);

View File

@@ -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>
) )
); );
}; };