1. Главная
  2. Docs
  3. Документация по инструментам и технологиям
  4. Cursor
  5. Mobile-разработка в Cursor. Практическое применение | Глава 14

Mobile-разработка в Cursor. Практическое применение | Глава 14

18 октября 2025
213

Глава 14. Mobile-разработка в Cursor. Практическое применение

14.1. React Native

Создание React Native компонента с Cursor


You: Создай React Native экран профиля пользователя с анимациями

AI:


// screens/ProfileScreen.tsx

import React, { useState, useRef } from 'react';
import {
View,
Text,
StyleSheet,
Image,
ScrollView,
TouchableOpacity,
Animated,
Dimensions,
RefreshControl,
Platform
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { BlurView } from 'expo-blur';

const { width, height } = Dimensions.get('window');
const HEADER_MAX_HEIGHT = 300;
const HEADER_MIN_HEIGHT = 60;
const HEADER_SCROLL_DISTANCE = HEADER_MAX_HEIGHT - HEADER_MIN_HEIGHT;

interface User {
id: string;
name: string;
username: string;
bio: string;
avatarUrl: string;
coverUrl: string;
stats: {
posts: number;
followers: number;
following: number;
};
}

interface ProfileScreenProps {
user: User;
onEditProfile: () => void;
onFollowToggle: () => void;
isFollowing: boolean;
isOwnProfile: boolean;
}

export const ProfileScreen: React.FC<ProfileScreenProps> = ({
user,
onEditProfile,
onFollowToggle,
isFollowing,
isOwnProfile
}) => {
const [refreshing, setRefreshing] = useState(false);
const scrollY = useRef(new Animated.Value(0)).current;

const headerTranslate = scrollY.interpolate({
inputRange: [0, HEADER_SCROLL_DISTANCE],
outputRange: [0, -HEADER_SCROLL_DISTANCE],
extrapolate: 'clamp'
});

const imageOpacity = scrollY.interpolate({
inputRange: [0, HEADER_SCROLL_DISTANCE / 2, HEADER_SCROLL_DISTANCE],
outputRange: [1, 0.5, 0],
extrapolate: 'clamp'
});

const imageTranslate = scrollY.interpolate({
inputRange: [0, HEADER_SCROLL_DISTANCE],
outputRange: [0, 100],
extrapolate: 'clamp'
});

const titleScale = scrollY.interpolate({
inputRange: [0, HEADER_SCROLL_DISTANCE / 2, HEADER_SCROLL_DISTANCE],
outputRange: [1, 1, 0.9],
extrapolate: 'clamp'
});

const titleTranslate = scrollY.interpolate({
inputRange: [0, HEADER_SCROLL_DISTANCE / 2, HEADER_SCROLL_DISTANCE],
outputRange: [0, 0, -8],
extrapolate: 'clamp'
});

const onRefresh = async () => {
setRefreshing(true);
// Fetch user data
setTimeout(() => setRefreshing(false), 2000);
};

const formatNumber = (num: number): string => {
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`;
return num.toString();
};

return (
<SafeAreaView style={styles.container} edges={['top']}>
{/* Fixed Header */}
<BlurView intensity={80} style={styles.fixedHeader}>
<View style={styles.headerContent}>
<TouchableOpacity style={styles.headerButton}>
<Ionicons name="arrow-back" size={24} color="#000" />
</TouchableOpacity>
<Animated.Text
style={[
styles.headerTitle,
{
opacity: scrollY.interpolate({
inputRange: [HEADER_SCROLL_DISTANCE - 50, HEADER_SCROLL_DISTANCE],
outputRange: [0, 1],
extrapolate: 'clamp'
})
}
]}
>
{user.name}
</Animated.Text>
<TouchableOpacity style={styles.headerButton}>
<Ionicons name="ellipsis-horizontal" size={24} color="#000" />
</TouchableOpacity>
</View>
</BlurView>

<Animated.ScrollView
style={styles.scrollView}
scrollEventThrottle={16}
onScroll={Animated.event(
[{ nativeEvent: { contentOffset: { y: scrollY } } }],
{ useNativeDriver: true }
)}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor="#007AFF"
/>
}
>
{/* Cover Image and Profile Header */}
<Animated.View
style={[
styles.header,
{
transform: [{ translateY: headerTranslate }]
}
]}
>
<Animated.Image
source={{ uri: user.coverUrl }}
style={[
styles.coverImage,
{
opacity: imageOpacity,
transform: [{ translateY: imageTranslate }]
}
]}
/>
<LinearGradient
colors={['transparent', 'rgba(0,0,0,0.7)']}
style={styles.gradient}
/>

<Animated.View
style={[
styles.profileInfo,
{
transform: [
{ scale: titleScale },
{ translateY: titleTranslate }
]
}
]}
>
<View style={styles.avatarContainer}>
<Image source={{ uri: user.avatarUrl }} style={styles.avatar} />
<View style={styles.avatarBadge}>
<Ionicons name="checkmark-circle" size={28} color="#007AFF" />
</View>
</View>

<Text style={styles.name}>{user.name}</Text>
<Text style={styles.username}>@{user.username}</Text>
<Text style={styles.bio}>{user.bio}</Text>

{/* Action Buttons */}
<View style={styles.actionButtons}>
{isOwnProfile ? (
<TouchableOpacity
style={styles.editButton}
onPress={onEditProfile}
activeOpacity={0.7}
>
<Ionicons name="create-outline" size={20} color="#007AFF" />
<Text style={styles.editButtonText}>Edit Profile</Text>
</TouchableOpacity>
) : (
<>
<TouchableOpacity
style={[
styles.followButton,
isFollowing && styles.followingButton
]}
onPress={onFollowToggle}
activeOpacity={0.7}
>
<Text
style={[
styles.followButtonText,
isFollowing && styles.followingButtonText
]}
>
{isFollowing ? 'Following' : 'Follow'}
</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.messageButton} activeOpacity={0.7}>
<Ionicons name="chatbubble-outline" size={20} color="#007AFF" />
</TouchableOpacity>
</>
)}
</View>
</Animated.View>
</Animated.View>

{/* Stats */}
<View style={styles.statsContainer}>
<StatItem label="Posts" value={formatNumber(user.stats.posts)} />
<View style={styles.statDivider} />
<StatItem label="Followers" value={formatNumber(user.stats.followers)} />
<View style={styles.statDivider} />
<StatItem label="Following" value={formatNumber(user.stats.following)} />
</View>

{/* Tabs */}
<View style={styles.tabs}>
<TabButton icon="grid-outline" active />
<TabButton icon="bookmark-outline" />
<TabButton icon="heart-outline" />
</View>

{/* Posts Grid */}
<View style={styles.postsGrid}>
{Array.from({ length: 12 }).map((_, index) => (
<TouchableOpacity key={index} style={styles.postItem} activeOpacity={0.8}>
<Image
source={{ uri: `https://picsum.photos/200?random=${index}` }}
style={styles.postImage}
/>
<View style={styles.postOverlay}>
<View style={styles.postStat}>
<Ionicons name="heart" size={18} color="#fff" />
<Text style={styles.postStatText}>
{formatNumber(Math.floor(Math.random() * 10000))}
</Text>
</View>
</View>
</TouchableOpacity>
))}
</View>
</Animated.ScrollView>
</SafeAreaView>
);
};

interface StatItemProps {
label: string;
value: string;
}

const StatItem: React.FC<StatItemProps> = ({ label, value }) => (
<TouchableOpacity style={styles.statItem} activeOpacity={0.7}>
<Text style={styles.statValue}>{value}</Text>
<Text style={styles.statLabel}>{label}</Text>
</TouchableOpacity>
);

interface TabButtonProps {
icon: string;
active?: boolean;
}

const TabButton: React.FC<TabButtonProps> = ({ icon, active = false }) => (
<TouchableOpacity
style={[styles.tabButton, active && styles.activeTabButton]}
activeOpacity={0.7}
>
<Ionicons
name={icon as any}
size={24}
color={active ? '#007AFF' : '#999'}
/>
</TouchableOpacity>
);

const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff'
},
fixedHeader: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 10,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: 'rgba(0,0,0,0.1)'
},
headerContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
paddingTop: Platform.OS === 'ios' ? 12 : 40
},
headerButton: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center'
},
headerTitle: {
fontSize: 18,
fontWeight: '600'
},
scrollView: {
flex: 1
},
header: {
height: HEADER_MAX_HEIGHT,
overflow: 'hidden'
},
coverImage: {
width: '100%',
height: HEADER_MAX_HEIGHT,
resizeMode: 'cover'
},
gradient: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: HEADER_MAX_HEIGHT / 2
},
profileInfo: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
alignItems: 'center',
paddingBottom: 20
},
avatarContainer: {
position: 'relative',
marginBottom: 12
},
avatar: {
width: 100,
height: 100,
borderRadius: 50,
borderWidth: 4,
borderColor: '#fff'
},
avatarBadge: {
position: 'absolute',
bottom: 0,
right: 0,
backgroundColor: '#fff',
borderRadius: 14
},
name: {
fontSize: 24,
fontWeight: '700',
color: '#fff',
marginBottom: 4
},
username: {
fontSize: 16,
color: 'rgba(255,255,255,0.8)',
marginBottom: 12
},
bio: {
fontSize: 14,
color: '#fff',
textAlign: 'center',
paddingHorizontal: 40,
marginBottom: 20
},
actionButtons: {
flexDirection: 'row',
gap: 12
},
editButton: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#fff',
paddingHorizontal: 24,
paddingVertical: 10,
borderRadius: 20,
gap: 6
},
editButtonText: {
fontSize: 15,
fontWeight: '600',
color: '#007AFF'
},
followButton: {
backgroundColor: '#007AFF',
paddingHorizontal: 32,
paddingVertical: 10,
borderRadius: 20
},
followingButton: {
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#ddd'
},
followButtonText: {
fontSize: 15,
fontWeight: '600',
color: '#fff'
},
followingButtonText: {
color: '#000'
},
messageButton: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center'
},
statsContainer: {
flexDirection: 'row',
backgroundColor: '#fff',
paddingVertical: 20,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#e0e0e0'
},
statItem: {
flex: 1,
alignItems: 'center'
},
statValue: {
fontSize: 20,
fontWeight: '700',
color: '#000',
marginBottom: 4
},
statLabel: {
fontSize: 13,
color: '#666'
},
statDivider: {
width: 1,
backgroundColor: '#e0e0e0'
},
tabs: {
flexDirection: 'row',
backgroundColor: '#fff',
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#e0e0e0'
},
tabButton: {
flex: 1,
paddingVertical: 12,
alignItems: 'center',
borderBottomWidth: 2,
borderBottomColor: 'transparent'
},
activeTabButton: {
borderBottomColor: '#007AFF'
},
postsGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
padding: 1
},
postItem: {
width: width / 3 - 2,
height: width / 3 - 2,
margin: 1,
position: 'relative'
},
postImage: {
width: '100%',
height: '100%'
},
postOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: '#000000',
alignItems: 'center',
justifyContent: 'center'
},
postStat: {
flexDirection: 'row',
alignItems: 'center',
gap: 4
},
postStatText: {
color: '#fff',
fontSize: 14,
fontWeight: '600'
}
});

Custom Hooks для React Native


You: Создай custom hooks для работы с камерой и геолокацией

AI:


// hooks/useCamera.ts

import { useState, useEffect } from 'react';
import { Camera } from 'expo-camera';
import { Alert, Platform } from 'react-native';
import * as ImagePicker from 'expo-image-picker';

export interface CameraResult {
uri: string;
width: number;
height: number;
type: 'image' | 'video';
}

export function useCamera() {
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
const [cameraRef, setCameraRef] = useState<Camera | null>(null);
const [isRecording, setIsRecording] = useState(false);

useEffect(() => {
requestPermission();
}, []);

const requestPermission = async () => {
const { status } = await Camera.requestCameraPermissionsAsync();
setHasPermission(status === 'granted');

if (status !== 'granted') {
Alert.alert(
'Permission Required',
'Camera permission is required to use this feature',
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Settings', onPress: () => {/* Open settings */} }
]
);
}
};

const takePicture = async (): Promise<CameraResult | null> => {
if (!cameraRef) return null;

try {
const photo = await cameraRef.takePictureAsync({
quality: 0.8,
skipProcessing: false
});

return {
uri: photo.uri,
width: photo.width,
height: photo.height,
type: 'image'
};
} catch (error) {
console.error('Error taking picture:', error);
Alert.alert('Error', 'Failed to take picture');
return null;
}
};

const startRecording = async (): Promise<void> => {
if (!cameraRef || isRecording) return;

try {
setIsRecording(true);
const video = await cameraRef.recordAsync({
quality: Camera.Constants.VideoQuality['720p'],
maxDuration: 60
});

console.log('Video recorded:', video.uri);
setIsRecording(false);
} catch (error) {
console.error('Error recording video:', error);
setIsRecording(false);
Alert.alert('Error', 'Failed to record video');
}
};

const stopRecording = () => {
if (cameraRef && isRecording) {
cameraRef.stopRecording();
setIsRecording(false);
}
};

const pickFromGallery = async (): Promise<CameraResult | null> => {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();

if (status !== 'granted') {
Alert.alert('Permission Required', 'Gallery access is required');
return null;
}

try {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.All,
allowsEditing: true,
quality: 0.8
});

if (!result.canceled && result.assets[0]) {
return {
uri: result.assets[0].uri,
width: result.assets[0].width,
height: result.assets[0].height,
type: result.assets[0].type === 'video' ? 'video' : 'image'
};
}

return null;
} catch (error) {
console.error('Error picking from gallery:', error);
Alert.alert('Error', 'Failed to pick image');
return null;
}
};

return {
hasPermission,
cameraRef,
setCameraRef,
isRecording,
takePicture,
startRecording,
stopRecording,
pickFromGallery,
requestPermission
};
}

// hooks/useLocation.ts
import { useState, useEffect } from 'react';
import * as Location from 'expo-location';
import { Alert } from 'react-native';

export interface LocationCoords {
latitude: number;
longitude: number;
altitude: number | null;
accuracy: number | null;
heading: number | null;
speed: number | null;
}

export interface LocationData {
coords: LocationCoords;
timestamp: number;
}

export function useLocation(options?: { watchPosition?: boolean }) {
const [location, setLocation] = useState<LocationData | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);

useEffect(() => {
let subscription: Location.LocationSubscription | null = null;

const setupLocation = async () => {
const { status } = await Location.requestForegroundPermissionsAsync();

if (status !== 'granted') {
setError('Location permission not granted');
Alert.alert(
'Permission Required',
'Location permission is required for this feature'
);
return;
}

if (options?.watchPosition) {
// Watch position continuously
subscription = await Location.watchPositionAsync(
{
accuracy: Location.Accuracy.High,
timeInterval: 1000,
distanceInterval: 10
},
(newLocation) => {
setLocation({
coords: newLocation.coords,
timestamp: newLocation.timestamp
});
}
);
} else {
// Get position once
getCurrentLocation();
}
};

setupLocation();

return () => {
if (subscription) {
subscription.remove();
}
};
}, [options?.watchPosition]);

const getCurrentLocation = async (): Promise<LocationData | null> => {
setLoading(true);
setError(null);

try {
const currentLocation = await Location.getCurrentPositionAsync({
accuracy: Location.Accuracy.High
});

const locationData: LocationData = {
coords: currentLocation.coords,
timestamp: currentLocation.timestamp
};

setLocation(locationData);
setLoading(false);
return locationData;
} catch (err) {
const errorMessage = 'Failed to get location';
setError(errorMessage);
setLoading(false);
Alert.alert('Error', errorMessage);
return null;
}
};

const getAddressFromCoords = async (
latitude: number,
longitude: number
): Promise<Location.LocationGeocodedAddress | null> => {
try {
const addresses = await Location.reverseGeocodeAsync({
latitude,
longitude
});

return addresses[0] || null;
} catch (err) {
console.error('Error reverse geocoding:', err);
return null;
}
};

const getCoordsFromAddress = async (
address: string
): Promise<LocationCoords | null> => {
try {
const results = await Location.geocodeAsync(address);

if (results.length > 0) {
return results[0] as LocationCoords;
}

return null;
} catch (err) {
console.error('Error geocoding:', err);
return null;
}
};

const calculateDistance = (
lat1: number,
lon1: number,
lat2: number,
lon2: number
): number => {
// Haversine formula
const R = 6371; // Earth's radius in km
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);

const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRad(lat1)) *
Math.cos(toRad(lat2)) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2);

const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c; // Distance in km
};

const toRad = (value: number): number => {
return (value * Math.PI) / 180;
};

return {
location,
error,
loading,
getCurrentLocation,
getAddressFromCoords,
getCoordsFromAddress,
calculateDistance
};
}

14.2. Flutter

Flutter Widget с Cursor


You: Создай Flutter виджет для карточки товара с анимациями

AI:


// widgets/product_card.dart

import 'package:flutter/material.dart';
import '../models/product.dart';

class ProductCard extends StatefulWidget {
final Product product;
final VoidCallback? onTap;
final VoidCallback? onAddToCart;
final VoidCallback? onToggleFavorite;
final bool isFavorite;

const ProductCard({
Key? key,
required this.product,
this.onTap,
this.onAddToCart,
this.onToggleFavorite,
this.isFavorite = false,
}) : super(key: key);

@override
State<ProductCard> createState() => _ProductCardState();
}

class _ProductCardState extends State<ProductCard>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
late Animation<double> _fadeAnimation;
bool _isHovered = false;

@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);

_scaleAnimation = Tween<double>(begin: 1.0, end: 0.95).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);

_fadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeIn),
);
}

@override
void dispose() {
_controller.dispose();
super.dispose();
}

void _onTapDown(TapDownDetails details) {
_controller.forward();
}

void _onTapUp(TapUpDetails details) {
_controller.reverse();
}

void _onTapCancel() {
_controller.reverse();
}

String _formatPrice(double price) {
return '\$${price.toStringAsFixed(2)}';
}

int _getDiscountPercentage() {
if (widget.product.originalPrice == null) return 0;
return (((widget.product.originalPrice! - widget.product.price) /
widget.product.originalPrice!) *
100)
.round();
}

@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: widget.onTap,
onTapDown: _onTapDown,
onTapUp: _onTapUp,
onTapCancel: _onTapCancel,
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: child,
);
},
child: MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(_isHovered ? 0.15 : 0.08),
blurRadius: _isHovered ? 20 : 10,
offset: Offset(0, _isHovered ? 8 : 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image Section
Stack(
children: [
_buildImage(),
_buildBadge(),
_buildFavoriteButton(),
if (_isHovered) _buildQuickViewOverlay(),
],
),

// Content Section
Expanded(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildProductName(),
const SizedBox(height: 4),
_buildRating(),
const Spacer(),
_buildPricing(),
const SizedBox(height: 8),
if (widget.product.inStock)
_buildAddToCartButton()
else
_buildOutOfStockLabel(),
],
),
),
),
],
),
),
),
),
);
}

Widget _buildImage() {
return ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
child: AspectRatio(
aspectRatio: 1,
child: Hero(
tag: 'product-${widget.product.id}',
child: Image.network(
widget.product.imageUrl,
fit: BoxFit.cover,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Container(
color: Colors.grey[200],
child: Center(
child: CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: null,
),
),
);
},
errorBuilder: (context, error, stackTrace) {
return Container(
color: Colors.grey[200],
child: const Icon(Icons.broken_image, size: 48),
);
},
),
),
),
);
}

Widget _buildBadge() {
if (widget.product.badge == null) return const SizedBox.shrink();

Color badgeColor;
String badgeText;

switch (widget.product.badge) {
case ProductBadge.newProduct:
badgeColor = Colors.green;
badgeText = 'NEW';
break;
case ProductBadge.sale:
badgeColor = Colors.red;
badgeText = '${_getDiscountPercentage()}% OFF';
break;
case ProductBadge.bestseller:
badgeColor = Colors.orange;
badgeText = 'BESTSELLER';
break;
default:
return const SizedBox.shrink();
}

return Positioned(
top: 12,
left: 12,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: badgeColor,
borderRadius: BorderRadius.circular(4),
),
child: Text(
badgeText,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
),
);
}

Widget _buildFavoriteButton() {
return Positioned(
top: 12,
right: 12,
child: GestureDetector(
onTap: widget.onToggleFavorite,
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Icon(
widget.isFavorite ? Icons.favorite : Icons.favorite_border,
color: widget.isFavorite ? Colors.red : Colors.grey[600],
size: 20,
),
),
),
);
}

Widget _buildQuickViewOverlay() {
return Positioned.fill(
child: FadeTransition(
opacity: _fadeAnimation,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withOpacity(0.7),
],
),
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
),
child: Center(
child: ElevatedButton(
onPressed: widget.onTap,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
),
child: const Text('Quick View'),
),
),
),
),
);
}

Widget _buildProductName() {
return Text(
widget.product.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
);
}

Widget _buildRating() {
return Row(
children: [
...List.generate(5, (index) {
return Icon(
index < widget.product.rating.floor()
? Icons.star
: Icons.star_border,
size: 14,
color: Colors.amber,
);
}),
const SizedBox(width: 4),
Text(
'(${widget.product.reviewCount})',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
);
}

Widget _buildPricing() {
return Row(
children: [
Text(
_formatPrice(widget.product.price),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
if (widget.product.originalPrice != null) ...[
const SizedBox(width: 8),
Text(
_formatPrice(widget.product.originalPrice!),
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
decoration: TextDecoration.lineThrough,
),
),
],
],
);
}

Widget _buildAddToCartButton() {
return SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: widget.onAddToCart,
icon: const Icon(Icons.shopping_cart, size: 16),
label: const Text('Add to Cart'),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
);
}

Widget _buildOutOfStockLabel() {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'Out of Stock',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.red,
fontWeight: FontWeight.w600,
),
),
);
}
}

// models/product.dart
enum ProductBadge {
newProduct,
sale,
bestseller,
}

class Product {
final String id;
final String name;
final String description;
final double price;
final double? originalPrice;
final String imageUrl;
final double rating;
final int reviewCount;
final bool inStock;
final ProductBadge? badge;

Product({
required this.id,
required this.name,
required this.description,
required this.price,
this.originalPrice,
required this.imageUrl,
required this.rating,
required this.reviewCount,
required this.inStock,
this.badge,
});

factory Product.fromJson(Map<String, dynamic> json) {
return Product(
id: json['id'],
name: json['name'],
description: json['description'],
price: json['price'].toDouble(),
originalPrice: json['originalPrice']?.toDouble(),
imageUrl: json['imageUrl'],
rating: json['rating'].toDouble(),
reviewCount: json['reviewCount'],
inStock: json['inStock'],
badge: json['badge'] != null
? ProductBadge.values.firstWhere(
(e) => e.toString() == 'ProductBadge.${json['badge']}',
)
: null,
);
}
}

14.3. Swift и iOS

SwiftUI View с Cursor


You: Создай SwiftUI экран списка задач с анимациями

AI:


// Views/TaskListView.swift

import SwiftUI

struct Task: Identifiable, Codable {
let id: UUID
var title: String
var description: String?
var isCompleted: Bool
var priority: Priority
var dueDate: Date?
var createdAt: Date

enum Priority: String, Codable, CaseIterable {
case low = "Low"
case medium = "Medium"
case high = "High"

var color: Color {
switch self {
case .low: return .green
case .medium: return .orange
case .high: return .red
}
}
}

init(
id: UUID = UUID(),
title: String,
description: String? = nil,
isCompleted: Bool = false,
priority: Priority = .medium,
dueDate: Date? = nil,
createdAt: Date = Date()
) {
self.id = id
self.title = title
self.description = description
self.isCompleted = isCompleted
self.priority = priority
self.dueDate = dueDate
self.createdAt = createdAt
}
}

class TaskViewModel: ObservableObject {
@Published var tasks: [Task] = []
@Published var filter: TaskFilter = .all
@Published var sortOrder: SortOrder = .date

enum TaskFilter {
case all
case active
case completed
}

enum SortOrder {
case date
case priority
case alphabetical
}

var filteredTasks: [Task] {
let filtered = tasks.filter { task in
switch filter {
case .all:
return true
case .active:
return !task.isCompleted
case .completed:
return task.isCompleted
}
}

return filtered.sorted { task1, task2 in
switch sortOrder {
case .date:
return task1.createdAt > task2.createdAt
case .priority:
let priorities: [Task.Priority] = [.high, .medium, .low]
let index1 = priorities.firstIndex(of: task1.priority) ?? 0
let index2 = priorities.firstIndex(of: task2.priority) ?? 0
return index1 < index2
case .alphabetical:
return task1.title < task2.title
}
}
}

func addTask(_ task: Task) {
withAnimation(.spring()) {
tasks.append(task)
}
}

func toggleCompletion(for task: Task) {
guard let index = tasks.firstIndex(where: { $0.id == task.id }) else {
return
}
withAnimation(.spring()) {
tasks[index].isCompleted.toggle()
}
}

func deleteTask(_ task: Task) {
withAnimation(.spring()) {
tasks.removeAll { $0.id == task.id }
}
}

func updateTask(_ task: Task) {
guard let index = tasks.firstIndex(where: { $0.id == task.id }) else {
return
}
withAnimation(.spring()) {
tasks[index] = task
}
}
}

struct TaskListView: View {
@StateObject private var viewModel = TaskViewModel()
@State private var showingAddTask = false
@State private var searchText = ""
@Namespace private var animation

var searchResults: [Task] {
if searchText.isEmpty {
return viewModel.filteredTasks
}
return viewModel.filteredTasks.filter {
$0.title.localizedCaseInsensitiveContains(searchText)
}
}

var body: some View {
NavigationView {
ZStack {
// Background
Color(.systemGroupedBackground)
.ignoresSafeArea()

VStack(spacing: 0) {
// Search Bar
SearchBar(text: $searchText)
.padding()

// Filter Tabs
FilterTabsView(selectedFilter: $viewModel.filter)
.padding(.horizontal)

// Task List
if searchResults.isEmpty {
EmptyStateView(filter: viewModel.filter)
} else {
ScrollView {
LazyVStack(spacing: 12) {
ForEach(searchResults) { task in
TaskRowView(
task: task,
onToggle: {
viewModel.toggleCompletion(for: task)
},
onDelete: {
viewModel.deleteTask(task)
}
)
.matchedGeometryEffect(id: task.id, in: animation)
}
}
.padding()
}
}
}
}
.navigationTitle("Tasks")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Menu {
Picker("Sort By", selection: $viewModel.sortOrder) {
Label("Date", systemImage: "calendar")
.tag(TaskViewModel.SortOrder.date)
Label("Priority", systemImage: "flag")
.tag(TaskViewModel.SortOrder.priority)
Label("Alphabetical", systemImage: "abc")
.tag(TaskViewModel.SortOrder.alphabetical)
}
} label: {
Image(systemName: "arrow.up.arrow.down.circle")
}
}

ToolbarItem(placement: .navigationBarTrailing) {
Button {
showingAddTask = true
} label: {
Image(systemName: "plus.circle.fill")
.font(.title2)
}
}
}
.sheet(isPresented: $showingAddTask) {
AddTaskView { task in
viewModel.addTask(task)
}
}
}
}
}

struct SearchBar: View {
@Binding var text: String

var body: some View {
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.gray)

TextField("Search tasks", text: $text)
.textFieldStyle(PlainTextFieldStyle())

if !text.isEmpty {
Button {
text = ""
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.gray)
}
}
}
.padding(10)
.background(Color(.systemGray6))
.cornerRadius(10)
}
}

struct FilterTabsView: View {
@Binding var selectedFilter: TaskViewModel.TaskFilter

var body: some View {
HStack(spacing: 0) {
FilterTab(title: "All", isSelected: selectedFilter == .all) {
withAnimation(.spring()) {
selectedFilter = .all
}
}

FilterTab(title: "Active", isSelected: selectedFilter == .active) {
withAnimation(.spring()) {
selectedFilter = .active
}
}

FilterTab(title: "Completed", isSelected: selectedFilter == .completed) {
withAnimation(.spring()) {
selectedFilter = .completed
}
}
}
.background(Color(.systemGray6))
.cornerRadius(10)
}
}

struct FilterTab: View {
let title: String
let isSelected: Bool
let action: () -> Void

var body: some View {
Button(action: action) {
Text(title)
.font(.subheadline)
.fontWeight(isSelected ? .semibold : .regular)
.foregroundColor(isSelected ? .white : .primary)
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(
isSelected ? Color.blue : Color.clear
)
.cornerRadius(8)
}
.buttonStyle(PlainButtonStyle())
}
}

struct TaskRowView: View {
let task: Task
let onToggle: () -> Void
let onDelete: () -> Void

@State private var offset: CGFloat = 0
@State private var isSwiped = false

var body: some View {
ZStack {
// Delete button background
HStack {
Spacer()
Button(action: onDelete) {
VStack {
Image(systemName: "trash")
Text("Delete")
.font(.caption)
}
.foregroundColor(.white)
.padding()
}
.frame(width: 80)
}
.background(Color.red)
.cornerRadius(12)

// Task content
HStack(spacing: 12) {
// Completion checkbox
Button(action: onToggle) {
Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
.font(.title2)
.foregroundColor(task.isCompleted ? .green : .gray)
}
.buttonStyle(PlainButtonStyle())

VStack(alignment: .leading, spacing: 4) {
Text(task.title)
.font(.body)
.fontWeight(.medium)
.strikethrough(task.isCompleted)
.foregroundColor(task.isCompleted ? .gray : .primary)

if let description = task.description {
Text(description)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(2)
}

HStack(spacing: 8) {
// Priority badge
HStack(spacing: 4) {
Circle()
.fill(task.priority.color)
.frame(width: 6, height: 6)
Text(task.priority.rawValue)
.font(.caption2)
.foregroundColor(.secondary)
}

// Due date
if let dueDate = task.dueDate {
HStack(spacing: 4) {
Image(systemName: "calendar")
.font(.caption2)
Text(dueDate, style: .date)
.font(.caption2)
}
.foregroundColor(.secondary)
}
}
}

Spacer()

Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.gray)
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(12)
.shadow(color: Color.black.opacity(0.05), radius: 5, x: 0, y: 2)
.offset(x: offset)
.gesture(
DragGesture()
.onChanged { value in
if value.translation.width < 0 {
offset = value.translation.width
}
}
.onEnded { value in
withAnimation(.spring()) {
if value.translation.width < -100 {
offset = -80
isSwiped = true
} else {
offset = 0
isSwiped = false
}
}
}
)
}
}
}

struct EmptyStateView: View {
let filter: TaskViewModel.TaskFilter

var body: some View {
VStack(spacing: 20) {
Image(systemName: "checklist")
.font(.system(size: 60))
.foregroundColor(.gray)

Text(emptyMessage)
.font(.headline)
.foregroundColor(.secondary)

Text("Add a new task to get started")
.font(.subheadline)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}

var emptyMessage: String {
switch filter {
case .all:
return "No tasks yet"
case .active:
return "No active tasks"
case .completed:
return "No completed tasks"
}
}
}

struct AddTaskView: View {
@Environment(\.dismiss) private var dismiss
@State private var title = ""
@State private var description = ""
@State private var priority: Task.Priority = .medium
@State private var dueDate: Date?
@State private var showDatePicker = false

let onAdd: (Task) -> Void

var body: some View {
NavigationView {
Form {
Section(header: Text("Task Details")) {
TextField("Title", text: $title)
TextField("Description (optional)", text: $description, axis: .vertical)
.lineLimit(3...6)
}

Section(header: Text("Priority")) {
Picker("Priority", selection: $priority) {
ForEach(Task.Priority.allCases, id: \.self) { priority in
HStack {
Circle()
.fill(priority.color)
.frame(width: 12, height: 12)
Text(priority.rawValue)
}
.tag(priority)
}
}
.pickerStyle(.segmented)
}

Section(header: Text("Due Date")) {
Toggle("Set due date", isOn: $showDatePicker)

if showDatePicker {
DatePicker(
"Due date",
selection: Binding(
get: { dueDate ?? Date() },
set: { dueDate = $0 }
),
displayedComponents: [.date]
)
}
}
}
.navigationTitle("New Task")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}

ToolbarItem(placement: .confirmationAction) {
Button("Add") {
let task = Task(
title: title,
description: description.isEmpty ? nil : description,
priority: priority,
dueDate: showDatePicker ? dueDate : nil
)
onAdd(task)
dismiss()
}
.disabled(title.isEmpty)
}
}
}
}
}

// Preview
struct TaskListView_Previews: PreviewProvider {
static var previews: some View {
TaskListView()
}
}

14.4. Kotlin и Android

Jetpack Compose UI с Cursor


You: Создай Android приложение на Jetpack Compose для заметок

AI:


// Note.kt

package com.example.notes.model

import androidx.room.Entity
import androidx.room.PrimaryKey
import java.util.Date
import java.util.UUID

@Entity(tableName = "notes")
data class Note(
@PrimaryKey
val id: String = UUID.randomUUID().toString(),
val title: String,
val content: String,
val color: NoteColor = NoteColor.DEFAULT,
val isPinned: Boolean = false,
val createdAt: Long = Date().time,
val updatedAt: Long = Date().time
)

enum class NoteColor(val colorValue: Long) {
DEFAULT(0xFFFFFFFF),
RED(0xFFFFCDD2),
ORANGE(0xFFFFE0B2),
YELLOW(0xFFFFF9C4),
GREEN(0xFFC8E6C9),
BLUE(0xFFBBDEFB),
PURPLE(0xFFE1BEE7);

companion object {
fun fromValue(value: Long): NoteColor {
return values().find { it.colorValue == value } ?: DEFAULT
}
}
}

// NoteViewModel.kt
package com.example.notes.viewmodel

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.notes.model.Note
import com.example.notes.repository.NoteRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject

sealed class NoteUiState {
object Loading : NoteUiState()
data class Success(val notes: List<Note>) : NoteUiState()
data class Error(val message: String) : NoteUiState()
}

@HiltViewModel
class NoteViewModel @Inject constructor(
private val repository: NoteRepository
) : ViewModel() {

private val _uiState = MutableStateFlow<NoteUiState>(NoteUiState.Loading)
val uiState: StateFlow<NoteUiState> = _uiState.asStateFlow()

private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()

init {
loadNotes()
}

fun loadNotes() {
viewModelScope.launch {
try {
repository.getAllNotes().collect { notes ->
val filteredNotes = if (_searchQuery.value.isNotEmpty()) {
notes.filter {
it.title.contains(_searchQuery.value, ignoreCase = true) ||
it.content.contains(_searchQuery.value, ignoreCase = true)
}
} else {
notes
}

val sortedNotes = filteredNotes.sortedWith(
compareByDescending<Note> { it.isPinned }
.thenByDescending { it.updatedAt }
)

_uiState.value = NoteUiState.Success(sortedNotes)
}
} catch (e: Exception) {
_uiState.value = NoteUiState.Error(e.message ?: "Unknown error")
}
}
}

fun addNote(note: Note) {
viewModelScope.launch {
repository.insertNote(note)
}
}

fun updateNote(note: Note) {
viewModelScope.launch {
repository.updateNote(note.copy(updatedAt = System.currentTimeMillis()))
}
}

fun deleteNote(note: Note) {
viewModelScope.launch {
repository.deleteNote(note)
}
}

fun togglePin(note: Note) {
viewModelScope.launch {
repository.updateNote(
note.copy(
isPinned = !note.isPinned,
updatedAt = System.currentTimeMillis()
)
)
}
}

fun updateSearchQuery(query: String) {
_searchQuery.value = query
loadNotes()
}
}

// NotesScreen.kt
package com.example.notes.ui.screen

import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.staggeredgrid.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.example.notes.model.Note
import com.example.notes.model.NoteColor
import com.example.notes.viewmodel.NoteUiState
import com.example.notes.viewmodel.NoteViewModel
import java.text.SimpleDateFormat
import java.util.*

@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun NotesScreen(
viewModel: NoteViewModel = hiltViewModel(),
onNoteClick: (Note) -> Unit,
onAddNoteClick: () -> Unit
) {
val uiState by viewModel.uiState.collectAsState()
val searchQuery by viewModel.searchQuery.collectAsState()
var showSearch by remember { mutableStateOf(false) }

Scaffold(
topBar = {
TopAppBar(
title = {
AnimatedContent(
targetState = showSearch,
transitionSpec = {
slideInVertically { -it } + fadeIn() with
slideOutVertically { -it } + fadeOut()
}
) { isSearching ->
if (isSearching) {
TextField(
value = searchQuery,
onValueChange = { viewModel.updateSearchQuery(it) },
placeholder = { Text("Search notes...") },
singleLine = true,
colors = TextFieldDefaults.textFieldColors(
containerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
),
modifier = Modifier.fillMaxWidth()
)
} else {
Text("My Notes")
}
}
},
actions = {
IconButton(onClick = {
showSearch = !showSearch
if (!showSearch) {
viewModel.updateSearchQuery("")
}
}) {
Icon(
imageVector = if (showSearch) Icons.Default.Close else Icons.Default.Search,
contentDescription = if (showSearch) "Close search" else "Search"
)
}
}
)
},
floatingActionButton = {
FloatingActionButton(
onClick = onAddNoteClick,
containerColor = MaterialTheme.colorScheme.primary
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = "Add note"
)
}
}
) { paddingValues ->
when (val state = uiState) {
is NoteUiState.Loading -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}

is NoteUiState.Success -> {
if (state.notes.isEmpty()) {
EmptyNotesState(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
)
} else {
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(2),
contentPadding = paddingValues,
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalItemSpacing = 8.dp,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 8.dp)
) {
items(
items = state.notes,
key = { it.id }
) { note ->
NoteCard(
note = note,
onClick = { onNoteClick(note) },
onPinClick = { viewModel.togglePin(note) },
onDeleteClick = { viewModel.deleteNote(note) },
modifier = Modifier.animateItemPlacement(
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
)
)
)
}
}
}
}

is NoteUiState.Error -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
Text(
text = "Error: ${state.message}",
color = MaterialTheme.colorScheme.error
)
}
}
}
}
}

@Composable
fun NoteCard(
note: Note,
onClick: () -> Unit,
onPinClick: () -> Unit,
onDeleteClick: () -> Unit,
modifier: Modifier = Modifier
) {
var showMenu by remember { mutableStateOf(false) }

Card(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = Color(note.color.colorValue)
),
elevation = CardDefaults.cardElevation(
defaultElevation = 4.dp
)
) {
Box {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
// Header with pin icon
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = note.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)

if (note.isPinned) {
Icon(
imageVector = Icons.Default.PushPin,
contentDescription = "Pinned",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
}
}

Spacer(modifier = Modifier.height(8.dp))

// Content
Text(
text = note.content,
style = MaterialTheme.typography.bodyMedium,
maxLines = 10,
overflow = TextOverflow.Ellipsis
)

Spacer(modifier = Modifier.height(8.dp))

// Date
Text(
text = formatDate(note.updatedAt),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}

// Menu button
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp)
) {
IconButton(onClick = { showMenu = true }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "More options",
modifier = Modifier.size(20.dp)
)
}

DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
DropdownMenuItem(
text = { Text(if (note.isPinned) "Unpin" else "Pin") },
onClick = {
onPinClick()
showMenu = false
},
leadingIcon = {
Icon(
imageVector = Icons.Default.PushPin,
contentDescription = null
)
}
)

DropdownMenuItem(
text = { Text("Delete") },
onClick = {
onDeleteClick()
showMenu = false
},
leadingIcon = {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
}
)
}
}
}
}
}

@Composable
fun EmptyNotesState(modifier: Modifier = Modifier) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Default.NoteAlt,
contentDescription = null,
modifier = Modifier.size(120.dp),
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f)
)

Spacer(modifier = Modifier.height(16.dp))

Text(
text = "No notes yet",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)

Spacer(modifier = Modifier.height(8.dp))

Text(
text = "Tap the + button to create your first note",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
)
}
}

private fun formatDate(timestamp: Long): String {
val sdf = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault())
return sdf.format(Date(timestamp))
}

14.5. Кросс-платформенная разработка

React Native + Expo универсальное приложение


You: Создай кросс-платформенное приложение с адаптивным дизайном

AI:


// App.tsx

import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { Provider } from 'react-redux';
import { store } from './store';
import { ThemeProvider } from './contexts/ThemeContext';
import { Platform } from 'react-native';

// Screens
import HomeScreen from './screens/HomeScreen';
import DetailsScreen from './screens/DetailsScreen';
import SettingsScreen from './screens/SettingsScreen';

const Stack = createNativeStackNavigator();

export default function App() {
return (
<Provider store={store}>
<ThemeProvider>
<SafeAreaProvider>
<NavigationContainer>
<Stack.Navigator
screenOptions={{
headerShown: Platform.OS !== 'web',
animation: Platform.select({
ios: 'slide_from_right',
android: 'slide_from_right',
default: 'none'
})
}}
>
<Stack.Screen
name="Home"
component={HomeScreen}
options={{ title: 'My App' }}
/>
<Stack.Screen
name="Details"
component={DetailsScreen}
/>
<Stack.Screen
name="Settings"
component={SettingsScreen}
/>
</Stack.Navigator>
</NavigationContainer>
</SafeAreaProvider>
</ThemeProvider>
</Provider>
);
}

// utils/responsive.ts
import { Dimensions, Platform, PixelRatio } from 'react-native';

const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');

// Based on iPhone 11 (414 x 896)
const baseWidth = 414;
const baseHeight = 896;

export const scale = (size: number): number => {
return (SCREEN_WIDTH / baseWidth) * size;
};

export const verticalScale = (size: number): number => {
return (SCREEN_HEIGHT / baseHeight) * size;
};

export const moderateScale = (size: number, factor: number = 0.5): number => {
return size + (scale(size) - size) * factor;
};

export const isTablet = (): boolean => {
const pixelDensity = PixelRatio.get();
const adjustedWidth = SCREEN_WIDTH * pixelDensity;
const adjustedHeight = SCREEN_HEIGHT * pixelDensity;

if (pixelDensity < 2 && (adjustedWidth >= 1000 || adjustedHeight >= 1000)) {
return true;
}

return pixelDensity === 2 && (adjustedWidth >= 1920 || adjustedHeight >= 1920);
};

export const isWeb = Platform.OS === 'web';
export const isIOS = Platform.OS === 'ios';
export const isAndroid = Platform.OS === 'android';

export const getResponsiveColumns = (): number => {
if (isWeb) {
if (SCREEN_WIDTH >= 1200) return 4;
if (SCREEN_WIDTH >= 768) return 3;
return 2;
}

if (isTablet()) return 3;
return 2;
};

// components/ResponsiveGrid.tsx
import React from 'react';
import {
View,
FlatList,
Dimensions,
StyleSheet,
Platform
} from 'react-native';
import { getResponsiveColumns } from '../utils/responsive';

interface ResponsiveGridProps<T> {
data: T[];
renderItem: (item: T, index: number) => React.ReactElement;
keyExtractor: (item: T, index: number) => string;
numColumns?: number;
gap?: number;
contentContainerStyle?: any;
}

export function ResponsiveGrid<T>({
data,
renderItem,
keyExtractor,
numColumns,
gap = 16,
contentContainerStyle
}: ResponsiveGridProps<T>) {
const [columns, setColumns] = React.useState(
numColumns || getResponsiveColumns()
);

React.useEffect(() => {
if (Platform.OS === 'web') {
const handleResize = () => {
setColumns(numColumns || getResponsiveColumns());
};

window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}
}, [numColumns]);

const formatData = (data: T[], numColumns: number) => {
const numberOfFullRows = Math.floor(data.length / numColumns);
let numberOfElementsLastRow = data.length - (numberOfFullRows * numColumns);

while (numberOfElementsLastRow !== numColumns && numberOfElementsLastRow !== 0) {
data.push({} as T);
numberOfElementsLastRow++;
}

return data;
};

return (
<FlatList
data={formatData(data, columns)}
renderItem={({ item, index }) => {
if (Object.keys(item).length === 0) {
return <View style={{ flex: 1, margin: gap / 2 }} />;
}

return (
<View style={{ flex: 1, margin: gap / 2 }}>
{renderItem(item, index)}
</View>
);
}}
keyExtractor={keyExtractor}
numColumns={columns}
key={columns} // Force re-render when columns change
contentContainerStyle={[
styles.container,
{ padding: gap / 2 },
contentContainerStyle
]}
/>
);
}

const styles = StyleSheet.create({
container: {
flexGrow: 1
}
});

// components/AdaptiveLayout.tsx
import React from 'react';
import { View, StyleSheet, useWindowDimensions } from 'react-native';

interface AdaptiveLayoutProps {
sidebar?: React.ReactNode;
children: React.ReactNode;
sidebarWidth?: number;
breakpoint?: number;
}

export const AdaptiveLayout: React.FC<AdaptiveLayoutProps> = ({
sidebar,
children,
sidebarWidth = 300,
breakpoint = 768
}) => {
const { width } = useWindowDimensions();
const showSidebar = width >= breakpoint && sidebar;

if (showSidebar) {
return (
<View style={styles.container}>
<View style={[styles.sidebar, { width: sidebarWidth }]}>
{sidebar}
</View>
<View style={styles.content}>
{children}
</View>
</View>
);
}

return <View style={styles.fullWidth}>{children}</View>;
};

const styles = StyleSheet.create({
container: {
flex: 1,
flexDirection: 'row'
},
sidebar: {
borderRightWidth: 1,
borderRightColor: '#e0e0e0'
},
content: {
flex: 1
},
fullWidth: {
flex: 1
}
});