Procházet zdrojové kódy

accordion for countries list + warning message

Viktoriia před 1 měsícem
rodič
revize
ee9b4fe8aa

+ 17 - 0
assets/icons/warning.svg

@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
+    <g>
+        <clipPath id="_clip1">
+            <rect x="0" y="0" width="24" height="24"/>
+        </clipPath>
+        <g clip-path="url(#_clip1)">
+            <g transform="matrix(0.0507813,0,0,0.0507813,-4.25001,-4.25001)">
+                <path d="M319.8,192C319.867,192 319.933,192 320,192C338.205,192.113 352.495,207.566 351.2,225.7L343.8,329.7C342.9,342.3 332.5,352 319.9,352C307.4,352 296.9,342.3 296,329.7L288.6,225.7C287.305,207.567 301.694,192.113 319.8,192ZM320,384C337.7,384 352,398.3 352,416C352,433.7 337.7,448 320,448C302.3,448 288,433.7 288,416C288,398.3 302.3,384 320,384Z" fill="currentColor"/>
+            </g>
+            <g transform="matrix(1.03171,0,0,1.00456,-0.380846,-0.10945)">
+                <path d="M12,24C5.59,24 0.369,18.639 0.369,12.055C0.369,5.47 5.59,0.109 12,0.109C18.411,0.109 23.632,5.47 23.632,12.055C23.632,18.639 18.411,24 12,24ZM12,21.73C17.217,21.73 21.421,17.412 21.421,12.055C21.421,6.697 17.217,2.379 12,2.379C6.784,2.379 2.58,6.697 2.58,12.055C2.58,17.412 6.784,21.73 12,21.73Z" style="fill-rule:nonzero;" fill="currentColor"/>
+            </g>
+        </g>
+    </g>
+</svg>

+ 11 - 0
src/modules/api/trips/trips-api.tsx

@@ -19,22 +19,33 @@ export interface PostGetTripsForYearReturn extends ResponseType {
       status: 0 | 1;
       id: number;
     }[];
+    dates_missing: 0 | 1;
   }[];
   statistics: {
+    dates_missing: 0 | 1;
     countries: {
       description: string;
       list: {
+        id: number;
         country: string;
         days_spent: number;
         flag: string | null;
+        regions: {
+          id: number;
+          region: string;
+          days_spent: number;
+        }[];
       }[];
     };
     general: [string];
     regions: {
       description: string;
       list: {
+        id: number;
         days_spent: number;
         flag: string | null;
+        flag1: string | null;
+        flag2: string | null;
         region: string;
       }[];
     };

+ 9 - 0
src/screens/InAppScreens/TravelsScreen/Components/TripItem/index.tsx

@@ -12,6 +12,7 @@ import { styles } from './styles';
 import CalendarIcon from 'assets/icons/travels-screens/calendar.svg';
 import EditIcon from 'assets/icons/travels-screens/pen-to-square.svg';
 import ArrowIcon from 'assets/icons/chevron-left.svg';
+import WarningIcon from 'assets/icons/warning.svg';
 
 const TripItem = ({ item, isNew }: { item: TripsData; isNew?: boolean }) => {
   const navigation = useNavigation();
@@ -39,6 +40,14 @@ const TripItem = ({ item, isNew }: { item: TripsData; isNew?: boolean }) => {
 
   return (
     <View style={styles.tripItemContainer}>
+      {isNew && item.dates_missing === 1 ? (
+        <View style={{ flexDirection: 'row', gap: 6, alignItems: 'center', marginBottom: 10 }}>
+          <WarningIcon color={Colors.RED} width={16} height={16} />
+          <Text style={{ fontSize: 14, fontWeight: '600', color: Colors.RED }}>
+            Fill in exact dates.
+          </Text>
+        </View>
+      ) : null}
       <View style={styles.tripHeaderContainer}>
         <View style={styles.tripDateContainer}>
           <CalendarIcon fill={Colors.DARK_BLUE} />

+ 147 - 0
src/screens/InAppScreens/TravelsScreen/RegionsVisitedScreen/AccordionListItem.tsx

@@ -0,0 +1,147 @@
+import React from 'react';
+import {
+  View,
+  Text,
+  TouchableOpacity,
+  Image,
+  LayoutAnimation,
+  Platform,
+  UIManager
+} from 'react-native';
+
+import ChevronIcon from '../../../../../assets/icons/chevron-left.svg';
+import { API_HOST } from 'src/constants';
+
+import { Colors } from 'src/theme';
+import { styles } from './styles';
+import { ItemStyles } from '../../TravellersScreen/Components/styles';
+import { NAVIGATION_PAGES } from 'src/types';
+
+interface CountryItem {
+  id: number;
+  name: string;
+  daysSpent: number;
+  flag: string | null;
+  regions: {
+    id: number;
+    region: string;
+    days_spent: number;
+  }[];
+  navigation: any;
+  expandedIds: number[];
+  setExpandedIds: (ids: number[]) => void;
+}
+
+if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
+  UIManager.setLayoutAnimationEnabledExperimental(true);
+}
+
+const getRegionSubname = (region?: string): string => {
+  if (!region) return '';
+
+  const [, ...rest] = region.split(/ – | - /);
+
+  return rest.join(' - ');
+};
+
+export const AccordionListItem = React.memo(
+  ({
+    id,
+    name,
+    daysSpent,
+    flag,
+    regions,
+    navigation,
+    expandedIds,
+    setExpandedIds
+  }: CountryItem) => {
+    const isExpanded = expandedIds.includes(id);
+
+    const toggleExpand = () => {
+      LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
+
+      if (isExpanded) {
+        setExpandedIds(expandedIds.filter((expId) => expId !== id));
+      } else {
+        setExpandedIds([...expandedIds, id]);
+      }
+    };
+
+    return (
+      <View style={styles.sectionContainer}>
+        <TouchableOpacity
+          onPress={toggleExpand}
+          style={[ItemStyles.wrapper, { paddingHorizontal: 8 }]}
+        >
+          <Image style={ItemStyles.bigFlag} source={{ uri: API_HOST + flag }} />
+
+          <View style={[ItemStyles.contentWrapper, { gap: 4 }]}>
+            <View style={{ flex: 1 }}>
+              <Text style={[{ color: Colors.DARK_BLUE, fontWeight: '600', fontSize: 15 }]}>
+                {name}
+              </Text>
+              {regions?.length ? (
+                <Text style={[{ color: Colors.TEXT_GRAY, fontSize: 13 }]}>
+                  {regions.length} regions visited
+                </Text>
+              ) : null}
+            </View>
+
+            <Text
+              style={[
+                ItemStyles.nameAndCnt,
+                { fontFamily: 'montserrat-700', fontSize: 15, paddingHorizontal: 4 }
+              ]}
+            >
+              {daysSpent}
+            </Text>
+          </View>
+
+          {/* <View style={styles.chevronContainer}>
+            <ChevronIcon
+              fill={Colors.DARK_BLUE}
+              style={[styles.headerIcon, isExpanded ? styles.rotate : null]}
+            />
+          </View> */}
+        </TouchableOpacity>
+
+        {isExpanded && (
+          <View style={[styles.content, { backgroundColor: Colors.FILL_LIGHT }]}>
+            <View style={{ height: 1, backgroundColor: '#E5E5E5', marginBottom: 6 }} />
+            {regions.map((r, index) => (
+              <TouchableOpacity
+                key={index}
+                style={[ItemStyles.wrapper, { paddingLeft: 20, paddingRight: 0 }]}
+                onPress={() =>
+                  navigation.navigate(NAVIGATION_PAGES.REGION_PREVIEW, {
+                    regionId: r.id,
+                    isTravelsScreen: true,
+                    type: 'nm',
+                    disabled: false
+                  })
+                }
+              >
+                <View style={[ItemStyles.contentWrapper, { gap: 4 }]}>
+                  <View style={{ flex: 1 }}>
+                    <Text style={[{ color: Colors.DARK_BLUE, fontWeight: '600', fontSize: 13 }]}>
+                      {getRegionSubname(r.region)}
+                    </Text>
+                  </View>
+
+                  <Text
+                    style={[
+                      ItemStyles.nameAndCnt,
+                      { fontFamily: 'montserrat-700', fontSize: 15, paddingHorizontal: 4 }
+                    ]}
+                  >
+                    {r.days_spent}
+                  </Text>
+                </View>
+              </TouchableOpacity>
+            ))}
+          </View>
+        )}
+      </View>
+    );
+  }
+);

+ 66 - 63
src/screens/InAppScreens/TravelsScreen/RegionsVisitedScreen/index.tsx

@@ -1,14 +1,14 @@
-import React, { useCallback, useEffect, useState } from 'react';
+import React, { useEffect, useState } from 'react';
 import { FlatList, Text, View, Image, TouchableOpacity } from 'react-native';
-import { useFocusEffect, useNavigation } from '@react-navigation/native';
+import { useNavigation } from '@react-navigation/native';
 
-import { Header, Loading, PageWrapper } from '../../../../components';
+import { Header, PageWrapper } from '../../../../components';
 
-import { getFontSize } from 'src/utils';
 import { ItemStyles } from '../../TravellersScreen/Components/styles';
 import { API_HOST } from 'src/constants';
 import { Colors } from 'src/theme';
 import { NAVIGATION_PAGES } from 'src/types';
+import { AccordionListItem } from './AccordionListItem';
 
 const RegionsVisitedScreen = ({ route }: { route: any }) => {
   const title = route.params.title;
@@ -18,6 +18,7 @@ const RegionsVisitedScreen = ({ route }: { route: any }) => {
 
   const [sorted, setSorted] = useState(data);
   const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
+  const [expandedIds, setExpandedIds] = useState<number[]>([]);
 
   useEffect(() => {
     setSorted([...data].sort((a, b) => b.days_spent - a.days_spent));
@@ -34,6 +35,10 @@ const RegionsVisitedScreen = ({ route }: { route: any }) => {
     }
   };
 
+  const sortedRegions = (regions: { id: number; region: string; days_spent: number }[]) => {
+    return regions.sort((a, b) => b.days_spent - a.days_spent);
+  };
+
   return (
     <PageWrapper style={{ flex: 1 }}>
       <Header label={title} />
@@ -43,81 +48,68 @@ const RegionsVisitedScreen = ({ route }: { route: any }) => {
           style={{ flex: 1 }}
           horizontal={false}
           data={sorted as any}
-          keyExtractor={(item, index) => index.toString()}
+          keyExtractor={(item, index) => item.id.toString()}
           initialNumToRender={20}
           renderItem={({ item, index }) => {
+            let subname = '';
             const [name, ...rest] = isRegion ? item.region?.split(/ – | - /) : ['', ''];
-            const subname = rest?.join(' - ');
+            if (isRegion) {
+              subname = rest?.join(' - ');
+            }
 
-            return (
+            return isRegion ? (
               <TouchableOpacity
                 onPress={() =>
-                  isRegion
-                    ? navigation.navigate(
-                        ...([
-                          NAVIGATION_PAGES.REGION_PREVIEW,
-                          {
-                            regionId: item.id,
-                            isTravelsScreen: true,
-                            type: 'nm',
-                            disabled: false
-                          }
-                        ] as never)
-                      )
-                    : navigation.navigate(
-                        ...([
-                          NAVIGATION_PAGES.COUNTRY_PREVIEW,
-                          {
-                            regionId: item.id,
-                            isTravelsScreen: true,
-                            type: 'country',
-                            disabled: false
-                          }
-                        ] as never)
-                      )
+                  navigation.navigate(
+                    ...([
+                      NAVIGATION_PAGES.REGION_PREVIEW,
+                      {
+                        regionId: item.id,
+                        isTravelsScreen: true,
+                        type: 'nm',
+                        disabled: false
+                      }
+                    ] as never)
+                  )
                 }
                 style={[ItemStyles.wrapper, { paddingHorizontal: 8 }]}
               >
-                {isRegion ? (
-                  <>
+                <>
+                  <Image
+                    source={{
+                      uri: API_HOST + item.flag1
+                    }}
+                    style={{
+                      width: 32,
+                      height: 32,
+                      borderRadius: 32 / 2,
+                      borderWidth: 1,
+                      borderColor: Colors.FILL_LIGHT
+                    }}
+                  />
+                  {item?.flag2 ? (
                     <Image
-                      source={{
-                        uri: API_HOST + item.flag1
-                      }}
-                      style={{
-                        width: 32,
-                        height: 32,
-                        borderRadius: 32 / 2,
-                        borderWidth: 1,
-                        borderColor: Colors.FILL_LIGHT
-                      }}
+                      source={{ uri: API_HOST + item.flag2 }}
+                      style={[
+                        {
+                          width: 32,
+                          height: 32,
+                          borderRadius: 32 / 2,
+                          borderWidth: 1,
+                          borderColor: Colors.FILL_LIGHT,
+                          marginLeft: -18
+                        }
+                      ]}
                     />
-                    {item?.flag2 ? (
-                      <Image
-                        source={{ uri: API_HOST + item.flag2 }}
-                        style={[
-                          {
-                            width: 32,
-                            height: 32,
-                            borderRadius: 32 / 2,
-                            borderWidth: 1,
-                            borderColor: Colors.FILL_LIGHT,
-                            marginLeft: -18
-                          }
-                        ]}
-                      />
-                    ) : null}
-                  </>
-                ) : (
-                  <Image style={ItemStyles.bigFlag} source={{ uri: API_HOST + item.flag }} />
-                )}
+                  ) : null}
+                </>
 
                 <View style={[ItemStyles.contentWrapper, { gap: 4 }]}>
                   <View style={{ flex: 1 }}>
                     <Text style={[{ color: Colors.DARK_BLUE, fontWeight: '600', fontSize: 15 }]}>
-                      {isRegion ? name : item.country}
+                      {name}
                     </Text>
-                    {isRegion && subname ? (
+                    {subname ? (
                       <Text style={[{ color: Colors.TEXT_GRAY, fontSize: 13 }]}>{subname}</Text>
                     ) : null}
                   </View>
@@ -132,6 +124,17 @@ const RegionsVisitedScreen = ({ route }: { route: any }) => {
                   </Text>
                 </View>
               </TouchableOpacity>
+            ) : (
+              <AccordionListItem
+                id={item.id}
+                name={item.country}
+                daysSpent={item.days_spent}
+                flag={item.flag}
+                regions={sortedRegions(item.regions)}
+                navigation={navigation}
+                expandedIds={expandedIds}
+                setExpandedIds={setExpandedIds}
+              />
             );
           }}
           showsVerticalScrollIndicator={false}

+ 82 - 0
src/screens/InAppScreens/TravelsScreen/RegionsVisitedScreen/styles.tsx

@@ -0,0 +1,82 @@
+import { StyleSheet, Dimensions } from 'react-native';
+import { Colors } from '../../../../theme';
+import { getFontSize } from 'src/utils';
+
+export const styles = StyleSheet.create({
+  header: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    paddingVertical: 10,
+    paddingHorizontal: 16,
+    justifyContent: 'space-between',
+    flex: 1
+  },
+  headerText: {
+    fontSize: 16,
+    fontWeight: 'bold',
+    color: '#0F3F4F',
+    flexShrink: 1,
+    marginRight: 10
+  },
+  headerImage: {
+    width: 30,
+    height: 30,
+    marginRight: 16,
+    resizeMode: 'contain',
+    borderRadius: 15,
+    borderColor: '#B4C2C7',
+    borderWidth: 0.5
+  },
+  contentText: {
+    fontSize: 13,
+    color: '#0F3F4F',
+    textAlign: 'left',
+    marginLeft: 10,
+    flexShrink: 1,
+    marginRight: 10
+  },
+  headerIcon: {
+    transform: [{ rotate: '-90deg' }]
+  },
+  itemIcon: {
+    width: 20,
+    height: 20,
+    marginLeft: 10,
+    resizeMode: 'contain'
+  },
+  rotate: {
+    transform: [{ rotate: '90deg' }]
+  },
+  content: {
+    padding: 6,
+    paddingBottom: 4,
+    backgroundColor: '#FAFAFA',
+    borderRadius: 8,
+  },
+  sectionContainer: {
+    backgroundColor: Colors.FILL_LIGHT,
+    borderRadius: 8
+  },
+  headerContainer: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    flex: 1
+  },
+  chevronContainer: {
+    width: 18,
+    height: 18,
+    alignItems: 'center'
+  },
+  itemContainer: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'space-between',
+    flex: 1
+  },
+  info: {
+    paddingHorizontal: 10,
+    height: '100%',
+    alignItems: 'center',
+    justifyContent: 'center'
+  }
+});

+ 44 - 26
src/screens/InAppScreens/TravelsScreen/Trips2025Screen/index.tsx

@@ -1,4 +1,4 @@
-import React, { useCallback, useEffect, useState } from 'react';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
 import { View, Text, TouchableOpacity, FlatList } from 'react-native';
 import { useFocusEffect, useNavigation } from '@react-navigation/native';
 
@@ -18,6 +18,8 @@ import InfoIcon from 'assets/icons/info-solid.svg';
 import { Colors } from 'src/theme';
 import ChevronLeftIcon from 'assets/icons/chevron-left.svg';
 import { getFontSize } from 'src/utils';
+import { FlashList, FlashListRef } from '@shopify/flash-list';
+import WarningIcon from 'assets/icons/warning.svg';
 
 const TripsScreen = ({ route }: { route: any }) => {
   const token = storage.get('token', StoreType.STRING) as string;
@@ -35,38 +37,34 @@ const TripsScreen = ({ route }: { route: any }) => {
     countries: {
       description: string;
       list: {
+        id: number;
         country: string;
         days_spent: number;
         flag: string | null;
+        regions: {
+          id: number;
+          region: string;
+          days_spent: number;
+        }[];
       }[];
     };
     general: [string];
     regions: {
       description: string;
       list: {
+        id: number;
         days_spent: number;
         flag: string | null;
+        flag1: string | null;
+        flag2: string | null;
         region: string;
       }[];
     };
+    dates_missing: 0 | 1;
   } | null>(null);
-  const [countriesByTime, setCountriesByTime] = useState<
-    {
-      country: string;
-      days_spent: number;
-      flag: string | null;
-    }[]
-  >([]);
-  const [regionsByTime, setRegionsByTime] = useState<
-    {
-      days_spent: number;
-      flag: string | null;
-      region: string;
-    }[]
-  >([]);
-
   const [isWarningModalVisible, setIsWarningModalVisible] = useState(false);
-  const [isExpanded, setIsExpanded] = useState(false);
+
+  const flashListRef = useRef<FlashListRef<any> | null>(null);
 
   useFocusEffect(
     useCallback(() => {
@@ -105,14 +103,6 @@ const TripsScreen = ({ route }: { route: any }) => {
     }
   }, [tripsData]);
 
-  useEffect(() => {
-    if (statistics) {
-      statistics.countries.list &&
-        setCountriesByTime(statistics.countries.list.sort((a, b) => b.days_spent - a.days_spent));
-      setRegionsByTime(statistics.regions.list.sort((a, b) => b.days_spent - a.days_spent));
-    }
-  }, [statistics]);
-
   const renderItem = useCallback(
     ({ item }: { item: TripsData }) => <TripItem item={item} isNew={true} />,
     []
@@ -122,6 +112,20 @@ const TripsScreen = ({ route }: { route: any }) => {
     navigation.navigate(NAVIGATION_PAGES.ADD_TRIP_2025 as never);
   }, [navigation]);
 
+  const handleScrollToTrip = () => {
+    if (!flashListRef?.current) return;
+
+    const index = trips.findIndex((trip) => trip.dates_missing === 1);
+
+    if (index === -1) return;
+
+    flashListRef.current.scrollToIndex({
+      index,
+      animated: true,
+      viewPosition: 0
+    });
+  };
+
   return (
     <PageWrapper>
       <View
@@ -161,7 +165,8 @@ const TripsScreen = ({ route }: { route: any }) => {
           <Text style={styles.noTripsText}>No trips at the moment</Text>
         </View>
       ) : (
-        <FlatList
+        <FlashList
+          ref={flashListRef}
           data={trips}
           renderItem={renderItem}
           keyExtractor={(item, index) => `${item.id}-${index}`}
@@ -223,6 +228,19 @@ const TripsScreen = ({ route }: { route: any }) => {
                   {statistics?.regions?.description}
                 </Text>
               </TouchableOpacity>
+
+              {statistics?.dates_missing ? (
+                <TouchableOpacity
+                  onPress={handleScrollToTrip}
+                  hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
+                  style={{ marginTop: 8, flexDirection: 'row', gap: 8, alignItems: 'center' }}
+                >
+                  <WarningIcon color={Colors.RED} width={20} height={20} />
+                  <Text style={{ fontSize: 14, fontWeight: '600', color: Colors.RED }}>
+                    For accurate annual statistics, please enter exact dates for all trips.
+                  </Text>
+                </TouchableOpacity>
+              ) : null}
             </View>
           }
         />

+ 1 - 0
src/screens/InAppScreens/TravelsScreen/utils/types.ts

@@ -56,6 +56,7 @@ export interface TripsData {
   date_to: string;
   description: string;
   regions: RegionTripsData[];
+  dates_missing: 0 | 1;
 }
 
 export interface RegionTripsData {