Improved MacOS support and organized screens and utils
This commit is contained in:
678
lib/utils/device_presets.dart
Normal file
678
lib/utils/device_presets.dart
Normal file
@@ -0,0 +1,678 @@
|
||||
|
||||
class DevicePreset {
|
||||
final String deviceType;
|
||||
final String userAgent;
|
||||
final String deviceName;
|
||||
final String osVersion;
|
||||
final String screen;
|
||||
final String timezone;
|
||||
final String locale;
|
||||
|
||||
DevicePreset({
|
||||
required this.deviceType,
|
||||
required this.userAgent,
|
||||
required this.deviceName,
|
||||
required this.osVersion,
|
||||
required this.screen,
|
||||
required this.timezone,
|
||||
required this.locale,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
final List<DevicePreset> devicePresets = [
|
||||
|
||||
DevicePreset(
|
||||
deviceType: 'ANDROID',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Linux; Android 14; SM-S928B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36',
|
||||
deviceName: 'Samsung Galaxy S24 Ultra',
|
||||
osVersion: 'Android 14',
|
||||
screen: '1440x3120 2.8x',
|
||||
timezone: 'Europe/Berlin',
|
||||
locale: 'de-DE',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'ANDROID',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36',
|
||||
deviceName: 'Google Pixel 8 Pro',
|
||||
osVersion: 'Android 14',
|
||||
screen: '1344x2992 2.7x',
|
||||
timezone: 'America/New_York',
|
||||
locale: 'en-US',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'ANDROID',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Linux; Android 13; 23021RAA2Y) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36',
|
||||
deviceName: 'Xiaomi 13 Pro',
|
||||
osVersion: 'Android 13',
|
||||
screen: '1440x3200 2.9x',
|
||||
timezone: 'Asia/Shanghai',
|
||||
locale: 'zh-CN',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'ANDROID',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Linux; Android 14; CPH2521) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36',
|
||||
deviceName: 'OnePlus 12',
|
||||
osVersion: 'Android 14',
|
||||
screen: '1440x3168 2.8x',
|
||||
timezone: 'Asia/Kolkata',
|
||||
locale: 'en-IN',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'ANDROID',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Linux; Android 13; SM-G998B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36',
|
||||
deviceName: 'Samsung Galaxy S21 Ultra',
|
||||
osVersion: 'Android 13',
|
||||
screen: '1440x3200 2.9x',
|
||||
timezone: 'Europe/London',
|
||||
locale: 'en-GB',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'ANDROID',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Linux; Android 12; Pixel 6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36',
|
||||
deviceName: 'Google Pixel 6',
|
||||
osVersion: 'Android 12',
|
||||
screen: '1080x2400 2.6x',
|
||||
timezone: 'America/Chicago',
|
||||
locale: 'en-US',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'ANDROID',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Linux; Android 13; RMX3371) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36',
|
||||
deviceName: 'Realme GT Master Edition',
|
||||
osVersion: 'Android 13',
|
||||
screen: '1080x2400 2.5x',
|
||||
timezone: 'Asia/Dubai',
|
||||
locale: 'ar-AE',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'ANDROID',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Linux; Android 11; M2101K6G) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Mobile Safari/537.36',
|
||||
deviceName: 'Poco F3',
|
||||
osVersion: 'Android 11',
|
||||
screen: '1080x2400 2.6x',
|
||||
timezone: 'Europe/Madrid',
|
||||
locale: 'es-ES',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'ANDROID',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Linux; Android 14; SO-51D) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36',
|
||||
deviceName: 'Sony Xperia 1 V',
|
||||
osVersion: 'Android 14',
|
||||
screen: '1644x3840 3.5x',
|
||||
timezone: 'Asia/Tokyo',
|
||||
locale: 'ja-JP',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'ANDROID',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Linux; Android 13; XT2201-2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36',
|
||||
deviceName: 'Motorola Edge 30 Pro',
|
||||
osVersion: 'Android 13',
|
||||
screen: '1080x2400 2.5x',
|
||||
timezone: 'America/Sao_Paulo',
|
||||
locale: 'pt-BR',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'ANDROID',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Linux; Android 14; SM-A546E) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36',
|
||||
deviceName: 'Samsung Galaxy A54',
|
||||
osVersion: 'Android 14',
|
||||
screen: '1080x2340 2.5x',
|
||||
timezone: 'Australia/Sydney',
|
||||
locale: 'en-AU',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'ANDROID',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Linux; Android 12; 2201116SG) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36',
|
||||
deviceName: 'Redmi Note 11 Pro',
|
||||
osVersion: 'Android 12',
|
||||
screen: '1080x2400 2.6x',
|
||||
timezone: 'Europe/Rome',
|
||||
locale: 'it-IT',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'ANDROID',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Linux; Android 13; ZS676KS) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36',
|
||||
deviceName: 'Asus ROG Phone 6',
|
||||
osVersion: 'Android 13',
|
||||
screen: '1080x2448 2.6x',
|
||||
timezone: 'Asia/Taipei',
|
||||
locale: 'zh-TW',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'ANDROID',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Linux; Android 10; TA-1021) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Mobile Safari/537.36',
|
||||
deviceName: 'Nokia 8',
|
||||
osVersion: 'Android 10',
|
||||
screen: '1440x2560 2.4x',
|
||||
timezone: 'Europe/Helsinki',
|
||||
locale: 'fi-FI',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'ANDROID',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Linux; Android 13; PGT-N19) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36',
|
||||
deviceName: 'Huawei P60 Pro',
|
||||
osVersion: 'Android 13 (EMUI)',
|
||||
screen: '1220x2700 2.7x',
|
||||
timezone: 'Europe/Paris',
|
||||
locale: 'fr-FR',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'ANDROID',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Linux; Android 9; LM-G710) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Mobile Safari/537.36',
|
||||
deviceName: 'LG G7 ThinQ',
|
||||
osVersion: 'Android 9',
|
||||
screen: '1440x3120 2.8x',
|
||||
timezone: 'Asia/Seoul',
|
||||
locale: 'ko-KR',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'ANDROID',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Linux; Android 14; Nothing Phone (2)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36',
|
||||
deviceName: 'Nothing Phone (2)',
|
||||
osVersion: 'Android 14',
|
||||
screen: '1080x2412 2.5x',
|
||||
timezone: 'America/Toronto',
|
||||
locale: 'en-CA',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'ANDROID',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Linux; Android 13; SM-F936U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36',
|
||||
deviceName: 'Samsung Galaxy Z Fold 4',
|
||||
osVersion: 'Android 13',
|
||||
screen: '1812x2176 2.2x',
|
||||
timezone: 'America/Denver',
|
||||
locale: 'en-US',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'ANDROID',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Linux; Android 12; LE2113) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36',
|
||||
deviceName: 'OnePlus 9',
|
||||
osVersion: 'Android 12',
|
||||
screen: '1080x2400 2.6x',
|
||||
timezone: 'Europe/Stockholm',
|
||||
locale: 'sv-SE',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'ANDROID',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Linux; Android 14; Pixel 7a) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36',
|
||||
deviceName: 'Google Pixel 7a',
|
||||
osVersion: 'Android 14',
|
||||
screen: '1080x2400 2.5x',
|
||||
timezone: 'Europe/Amsterdam',
|
||||
locale: 'nl-NL',
|
||||
),
|
||||
|
||||
|
||||
DevicePreset(
|
||||
deviceType: 'IOS',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1',
|
||||
deviceName: 'iPhone 15 Pro Max',
|
||||
osVersion: 'iOS 17.5.1',
|
||||
screen: '1290x2796 3.0x',
|
||||
timezone: 'America/Los_Angeles',
|
||||
locale: 'en-US',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'IOS',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1',
|
||||
deviceName: 'iPhone 13',
|
||||
osVersion: 'iOS 16.7',
|
||||
screen: '1170x2532 3.0x',
|
||||
timezone: 'Europe/London',
|
||||
locale: 'en-GB',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'IOS',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/124.0.6367.88 Mobile/15E148 Safari/604.1',
|
||||
deviceName: 'iPad Pro 11-inch',
|
||||
osVersion: 'iPadOS 17.5',
|
||||
screen: '1668x2388 2.0x',
|
||||
timezone: 'Europe/Paris',
|
||||
locale: 'fr-FR',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'IOS',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/125.0 Mobile/15E148',
|
||||
deviceName: 'iPhone 14 Pro',
|
||||
osVersion: 'iOS 17.4.1',
|
||||
screen: '1179x2556 3.0x',
|
||||
timezone: 'Europe/Berlin',
|
||||
locale: 'de-DE',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'IOS',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 15_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.3 Mobile/15E148 Safari/604.1',
|
||||
deviceName: 'iPhone SE (2020)',
|
||||
osVersion: 'iOS 15.8',
|
||||
screen: '750x1334 2.0x',
|
||||
timezone: 'Australia/Melbourne',
|
||||
locale: 'en-AU',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'IOS',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) OPR/55.0.2519.144889 Mobile/15E148',
|
||||
deviceName: 'iPhone 15',
|
||||
osVersion: 'iOS 17.1',
|
||||
screen: '1179x2556 3.0x',
|
||||
timezone: 'Asia/Tokyo',
|
||||
locale: 'ja-JP',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'IOS',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (iPad; CPU OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1',
|
||||
deviceName: 'iPad Air 5th Gen',
|
||||
osVersion: 'iPadOS 16.5',
|
||||
screen: '1640x2360 2.0x',
|
||||
timezone: 'America/Toronto',
|
||||
locale: 'en-CA',
|
||||
),
|
||||
|
||||
|
||||
DevicePreset(
|
||||
deviceType: 'DESKTOP',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
||||
deviceName: 'Windows PC',
|
||||
osVersion: 'Windows 11',
|
||||
screen: '1920x1080 1.25x',
|
||||
timezone: 'Europe/Moscow',
|
||||
locale: 'ru-RU',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'DESKTOP',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
||||
deviceName: 'MacBook Pro',
|
||||
osVersion: 'macOS 14.5 Sonoma',
|
||||
screen: '1728x1117 2.0x',
|
||||
timezone: 'America/New_York',
|
||||
locale: 'en-US',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'DESKTOP',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0',
|
||||
deviceName: 'Linux PC',
|
||||
osVersion: 'Ubuntu 24.04 LTS',
|
||||
screen: '2560x1440 1.0x',
|
||||
timezone: 'UTC',
|
||||
locale: 'en-GB',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'DESKTOP',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0',
|
||||
deviceName: 'Windows PC (Firefox)',
|
||||
osVersion: 'Windows 10',
|
||||
screen: '1536x864 1.0x',
|
||||
timezone: 'Europe/Paris',
|
||||
locale: 'fr-FR',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'DESKTOP',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15',
|
||||
deviceName: 'iMac (Safari)',
|
||||
osVersion: 'macOS 13.6 Ventura',
|
||||
screen: '3840x2160 1.5x',
|
||||
timezone: 'America/Los_Angeles',
|
||||
locale: 'en-US',
|
||||
),
|
||||
|
||||
|
||||
|
||||
DevicePreset(
|
||||
deviceType: 'WEB',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
|
||||
deviceName: 'Chrome',
|
||||
osVersion: 'Windows ',
|
||||
screen: '1920x1080',
|
||||
timezone: 'Europe/Berlin',
|
||||
locale: 'de-DE',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'WEB',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
||||
deviceName: 'Chrome',
|
||||
osVersion: 'Windows',
|
||||
screen: '2560x1440',
|
||||
timezone: 'America/New_York',
|
||||
locale: 'en-US',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'WEB',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.2420.97',
|
||||
deviceName: 'Edge',
|
||||
osVersion: 'Windows',
|
||||
screen: '1536x864',
|
||||
timezone: 'Europe/London',
|
||||
locale: 'en-GB',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'WEB',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
|
||||
deviceName: 'Chrome',
|
||||
osVersion: 'Windows',
|
||||
screen: '1920x1200',
|
||||
timezone: 'Europe/Paris',
|
||||
locale: 'fr-FR',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'WEB',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
||||
deviceName: 'Chrome',
|
||||
osVersion: 'Windows',
|
||||
screen: '1366x768',
|
||||
timezone: 'Europe/Madrid',
|
||||
locale: 'es-ES',
|
||||
),
|
||||
|
||||
|
||||
DevicePreset(
|
||||
deviceType: 'WEB',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0',
|
||||
deviceName: 'Firefox',
|
||||
osVersion: 'Windows',
|
||||
screen: '1920x1080',
|
||||
timezone: 'Europe/Rome',
|
||||
locale: 'it-IT',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'WEB',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0',
|
||||
deviceName: 'Firefox',
|
||||
osVersion: 'Windows',
|
||||
screen: '1440x900',
|
||||
timezone: 'Europe/Amsterdam',
|
||||
locale: 'nl-NL',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'WEB',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Windows NT 6.3; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0',
|
||||
deviceName: 'Firefox',
|
||||
osVersion: 'Windows',
|
||||
screen: '1600x900',
|
||||
timezone: 'Europe/Warsaw',
|
||||
locale: 'pl-PL',
|
||||
),
|
||||
|
||||
|
||||
DevicePreset(
|
||||
deviceType: 'WEB',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 Edg/125.0.2535.51',
|
||||
deviceName: 'Edge',
|
||||
osVersion: 'Windows',
|
||||
screen: '1920x1080',
|
||||
timezone: 'America/Chicago',
|
||||
locale: 'en-US',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'WEB',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.2478.109',
|
||||
deviceName: 'Edge',
|
||||
osVersion: 'Windows',
|
||||
screen: '1366x768',
|
||||
timezone: 'America/Sao_Paulo',
|
||||
locale: 'pt-BR',
|
||||
),
|
||||
|
||||
|
||||
DevicePreset(
|
||||
deviceType: 'WEB',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
|
||||
deviceName: 'Chrome',
|
||||
osVersion: 'macOS 14.5',
|
||||
screen: '2560x1440',
|
||||
timezone: 'America/Los_Angeles',
|
||||
locale: 'en-US',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'WEB',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
||||
deviceName: 'Chrome',
|
||||
osVersion: 'macOS 13.6',
|
||||
screen: '1440x900',
|
||||
timezone: 'America/Toronto',
|
||||
locale: 'en-CA',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'WEB',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 11_7_10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
|
||||
deviceName: 'Chrome',
|
||||
osVersion: 'macOS 11.7',
|
||||
screen: '1728x1117',
|
||||
timezone: 'Australia/Sydney',
|
||||
locale: 'en-AU',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'WEB',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
|
||||
deviceName: 'Chrome',
|
||||
osVersion: 'macOS 12.5',
|
||||
screen: '2048x1152',
|
||||
timezone: 'Europe/London',
|
||||
locale: 'en-GB',
|
||||
),
|
||||
|
||||
|
||||
DevicePreset(
|
||||
deviceType: 'WEB',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:126.0) Gecko/20100101 Firefox/126.0',
|
||||
deviceName: 'Firefox',
|
||||
osVersion: 'macOS 14.5',
|
||||
screen: '1920x1080',
|
||||
timezone: 'America/New_York',
|
||||
locale: 'en-US',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'WEB',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:125.0) Gecko/20100101 Firefox/125.0',
|
||||
deviceName: 'Firefox',
|
||||
osVersion: 'macOS 13.0',
|
||||
screen: '1680x1050',
|
||||
timezone: 'Europe/Berlin',
|
||||
locale: 'de-DE',
|
||||
),
|
||||
|
||||
|
||||
DevicePreset(
|
||||
deviceType: 'WEB',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15',
|
||||
deviceName: 'Safari',
|
||||
osVersion: 'macOS 14.5',
|
||||
screen: '1440x900',
|
||||
timezone: 'America/New_York',
|
||||
locale: 'en-US',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'WEB',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15',
|
||||
deviceName: 'Safari',
|
||||
osVersion: 'macOS 13.6',
|
||||
screen: '2560x1600',
|
||||
timezone: 'Europe/Paris',
|
||||
locale: 'fr-FR',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'WEB',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15',
|
||||
deviceName: 'Safari',
|
||||
osVersion: 'macOS 10.14',
|
||||
screen: '1280x800',
|
||||
timezone: 'Asia/Tokyo',
|
||||
locale: 'ja-JP',
|
||||
),
|
||||
|
||||
|
||||
DevicePreset(
|
||||
deviceType: 'WEB',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
|
||||
deviceName: 'Chrome',
|
||||
osVersion: 'Linux',
|
||||
screen: '1920x1080',
|
||||
timezone: 'Europe/Moscow',
|
||||
locale: 'ru-RU',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'WEB',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
|
||||
deviceName: 'Chrome',
|
||||
osVersion: 'Linux',
|
||||
screen: '1366x768',
|
||||
timezone: 'Asia/Kolkata',
|
||||
locale: 'en-IN',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'WEB',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
||||
deviceName: 'Chrome',
|
||||
osVersion: 'Chrome OS',
|
||||
screen: '1920x1080',
|
||||
timezone: 'America/Mexico_City',
|
||||
locale: 'es-MX',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'WEB',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
|
||||
deviceName: 'Chrome',
|
||||
osVersion: 'Linux',
|
||||
screen: '1600x900',
|
||||
timezone: 'Asia/Shanghai',
|
||||
locale: 'zh-CN',
|
||||
),
|
||||
|
||||
|
||||
DevicePreset(
|
||||
deviceType: 'WEB',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:126.0) Gecko/20100101 Firefox/126.0',
|
||||
deviceName: 'Firefox',
|
||||
osVersion: 'Linux',
|
||||
screen: '1920x1080',
|
||||
timezone: 'UTC',
|
||||
locale: 'en-GB',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'WEB',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0',
|
||||
deviceName: 'Firefox',
|
||||
osVersion: 'Linux',
|
||||
screen: '2560x1440',
|
||||
timezone: 'America/Denver',
|
||||
locale: 'en-US',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'WEB',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0',
|
||||
deviceName: 'Firefox',
|
||||
osVersion: 'Linux',
|
||||
screen: '1366x768',
|
||||
timezone: 'Asia/Dubai',
|
||||
locale: 'ar-AE',
|
||||
),
|
||||
|
||||
|
||||
DevicePreset(
|
||||
deviceType: 'WEB',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 OPR/110.0.0.0',
|
||||
deviceName: 'Opera',
|
||||
osVersion: 'Windows',
|
||||
screen: '1920x1080',
|
||||
timezone: 'Europe/Oslo',
|
||||
locale: 'no-NO',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'WEB',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Vivaldi/6.5.3206.63',
|
||||
deviceName: 'Vivaldi',
|
||||
osVersion: 'macOS 14.0',
|
||||
screen: '1440x900',
|
||||
timezone: 'Europe/Stockholm',
|
||||
locale: 'sv-SE',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'WEB',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Windows NT 10.0; rv:102.0) Gecko/20100101 Firefox/102.0',
|
||||
deviceName: 'Firefox',
|
||||
osVersion: 'Windows',
|
||||
screen: '1280x720',
|
||||
timezone: 'Asia/Seoul',
|
||||
locale: 'ko-KR',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'WEB',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36',
|
||||
deviceName: 'Chrome',
|
||||
osVersion: 'Linux',
|
||||
screen: '1920x1080',
|
||||
timezone: 'Europe/Helsinki',
|
||||
locale: 'fi-FI',
|
||||
),
|
||||
DevicePreset(
|
||||
deviceType: 'WEB',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.2 Safari/605.1.15',
|
||||
deviceName: 'Safari',
|
||||
osVersion: 'macOS 10.13',
|
||||
screen: '1280x800',
|
||||
timezone: 'America/Vancouver',
|
||||
locale: 'en-CA',
|
||||
),
|
||||
];
|
||||
935
lib/utils/full_screen_video_player.dart
Normal file
935
lib/utils/full_screen_video_player.dart
Normal file
@@ -0,0 +1,935 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
import 'dart:async';
|
||||
|
||||
class FullScreenVideoPlayer extends StatefulWidget {
|
||||
final String videoUrl;
|
||||
|
||||
const FullScreenVideoPlayer({Key? key, required this.videoUrl})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
State<FullScreenVideoPlayer> createState() => _FullScreenVideoPlayerState();
|
||||
}
|
||||
|
||||
class _FullScreenVideoPlayerState extends State<FullScreenVideoPlayer>
|
||||
with SingleTickerProviderStateMixin {
|
||||
VideoPlayerController? _videoPlayerController;
|
||||
bool _isLoading = true;
|
||||
bool _hasError = false;
|
||||
bool _isPlaying = false;
|
||||
bool _showControls = true;
|
||||
bool _isBuffering = false;
|
||||
double _playbackSpeed = 1.0;
|
||||
Timer? _hideControlsTimer;
|
||||
Timer? _positionTimer;
|
||||
late AnimationController _controlsAnimationController;
|
||||
late Animation<double> _controlsAnimation;
|
||||
bool _isDragging = false;
|
||||
Duration _currentPosition = Duration.zero;
|
||||
Duration _totalDuration = Duration.zero;
|
||||
List<DurationRange> _bufferedRanges = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.landscapeLeft,
|
||||
DeviceOrientation.landscapeRight,
|
||||
DeviceOrientation.portraitUp,
|
||||
]);
|
||||
|
||||
_controlsAnimationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
);
|
||||
|
||||
_controlsAnimation = CurvedAnimation(
|
||||
parent: _controlsAnimationController,
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
|
||||
_controlsAnimationController.forward();
|
||||
_initializePlayer();
|
||||
}
|
||||
|
||||
Future<void> _initializePlayer() async {
|
||||
try {
|
||||
_videoPlayerController = VideoPlayerController.networkUrl(
|
||||
Uri.parse(widget.videoUrl),
|
||||
httpHeaders: const {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
},
|
||||
);
|
||||
|
||||
_videoPlayerController!.addListener(_videoListener);
|
||||
await _videoPlayerController!.initialize();
|
||||
_videoPlayerController!.play();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_isPlaying = true;
|
||||
_totalDuration = _videoPlayerController!.value.duration;
|
||||
_currentPosition = _videoPlayerController!.value.position;
|
||||
});
|
||||
_startHideControlsTimer();
|
||||
_startPositionTimer();
|
||||
}
|
||||
} catch (e) {
|
||||
print('❌ [FullScreenVideoPlayer] Error initializing player: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_hasError = true;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _videoListener() {
|
||||
if (!mounted) return;
|
||||
|
||||
final controller = _videoPlayerController!;
|
||||
setState(() {
|
||||
_isPlaying = controller.value.isPlaying;
|
||||
_isBuffering = controller.value.isBuffering;
|
||||
_totalDuration = controller.value.duration;
|
||||
_bufferedRanges = controller.value.buffered;
|
||||
if (!_isDragging) {
|
||||
_currentPosition = controller.value.position;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _startHideControlsTimer() {
|
||||
_hideControlsTimer?.cancel();
|
||||
_hideControlsTimer = Timer(const Duration(seconds: 3), () {
|
||||
if (_isPlaying && !_isDragging) {
|
||||
_hideControlsUI();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _startPositionTimer() {
|
||||
_positionTimer?.cancel();
|
||||
_positionTimer = Timer.periodic(const Duration(milliseconds: 100), (timer) {
|
||||
if (!mounted || _isDragging) return;
|
||||
if (_videoPlayerController != null &&
|
||||
_videoPlayerController!.value.isInitialized) {
|
||||
setState(() {
|
||||
_currentPosition = _videoPlayerController!.value.position;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _showControlsUI() {
|
||||
if (_showControls) return;
|
||||
setState(() {
|
||||
_showControls = true;
|
||||
});
|
||||
_controlsAnimationController.forward();
|
||||
_startHideControlsTimer();
|
||||
}
|
||||
|
||||
void _hideControlsUI() {
|
||||
if (!_showControls) return;
|
||||
setState(() {
|
||||
_showControls = false;
|
||||
});
|
||||
_controlsAnimationController.reverse();
|
||||
}
|
||||
|
||||
void _togglePlayPause() {
|
||||
setState(() {
|
||||
if (_isPlaying) {
|
||||
_videoPlayerController!.pause();
|
||||
_showControlsUI();
|
||||
} else {
|
||||
_videoPlayerController!.play();
|
||||
_startHideControlsTimer();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Duration _clampDuration(Duration value, Duration min, Duration max) {
|
||||
if (value < min) return min;
|
||||
if (value > max) return max;
|
||||
return value;
|
||||
}
|
||||
|
||||
void _seekTo(Duration position) {
|
||||
_videoPlayerController!.seekTo(position);
|
||||
setState(() {
|
||||
_currentPosition = position;
|
||||
_isDragging = false;
|
||||
});
|
||||
_startHideControlsTimer();
|
||||
}
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
String twoDigits(int n) => n.toString().padLeft(2, '0');
|
||||
final hours = duration.inHours;
|
||||
final minutes = duration.inMinutes.remainder(60);
|
||||
final seconds = duration.inSeconds.remainder(60);
|
||||
|
||||
if (hours > 0) {
|
||||
return '${twoDigits(hours)}:${twoDigits(minutes)}:${twoDigits(seconds)}';
|
||||
}
|
||||
return '${twoDigits(minutes)}:${twoDigits(seconds)}';
|
||||
}
|
||||
|
||||
void _showSpeedMenu() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => _SpeedBottomSheet(
|
||||
currentSpeed: _playbackSpeed,
|
||||
onSpeedSelected: (speed) {
|
||||
setState(() {
|
||||
_playbackSpeed = speed;
|
||||
_videoPlayerController!.setPlaybackSpeed(speed);
|
||||
});
|
||||
Navigator.pop(context);
|
||||
_showControlsUI();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hideControlsTimer?.cancel();
|
||||
_positionTimer?.cancel();
|
||||
_videoPlayerController?.removeListener(_videoListener);
|
||||
_videoPlayerController?.dispose();
|
||||
_controlsAnimationController.dispose();
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.portraitUp,
|
||||
DeviceOrientation.portraitDown,
|
||||
DeviceOrientation.landscapeLeft,
|
||||
DeviceOrientation.landscapeRight,
|
||||
]);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
children: [
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: () {
|
||||
if (_showControls) {
|
||||
_hideControlsUI();
|
||||
} else {
|
||||
_showControlsUI();
|
||||
}
|
||||
},
|
||||
onDoubleTapDown: (details) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
if (details.globalPosition.dx < screenWidth / 2) {
|
||||
final newPosition = _clampDuration(
|
||||
_currentPosition - const Duration(seconds: 10),
|
||||
Duration.zero,
|
||||
_totalDuration,
|
||||
);
|
||||
_seekTo(newPosition);
|
||||
_showControlsUI();
|
||||
} else {
|
||||
final newPosition = _clampDuration(
|
||||
_currentPosition + const Duration(seconds: 10),
|
||||
Duration.zero,
|
||||
_totalDuration,
|
||||
);
|
||||
_seekTo(newPosition);
|
||||
_showControlsUI();
|
||||
}
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: _isLoading
|
||||
? CircularProgressIndicator(
|
||||
color: colorScheme.primary,
|
||||
)
|
||||
: _hasError
|
||||
? _ErrorWidget(colorScheme: colorScheme)
|
||||
: _videoPlayerController != null &&
|
||||
_videoPlayerController!.value.isInitialized
|
||||
? AspectRatio(
|
||||
aspectRatio: _videoPlayerController!.value.aspectRatio,
|
||||
child: VideoPlayer(_videoPlayerController!),
|
||||
)
|
||||
: const SizedBox(),
|
||||
),
|
||||
|
||||
if (_isBuffering)
|
||||
Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.7),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
color: colorScheme.primary,
|
||||
strokeWidth: 3,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Буферизация...',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_showControls)
|
||||
GestureDetector(
|
||||
onDoubleTapDown: (details) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
if (details.globalPosition.dx < screenWidth / 2) {
|
||||
final newPosition = _clampDuration(
|
||||
_currentPosition - const Duration(seconds: 10),
|
||||
Duration.zero,
|
||||
_totalDuration,
|
||||
);
|
||||
_seekTo(newPosition);
|
||||
_showControlsUI();
|
||||
} else {
|
||||
final newPosition = _clampDuration(
|
||||
_currentPosition + const Duration(seconds: 10),
|
||||
Duration.zero,
|
||||
_totalDuration,
|
||||
);
|
||||
_seekTo(newPosition);
|
||||
_showControlsUI();
|
||||
}
|
||||
},
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: AnimatedBuilder(
|
||||
animation: _controlsAnimation,
|
||||
builder: (context, child) {
|
||||
return Opacity(
|
||||
opacity: _controlsAnimation.value,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: _VideoControls(
|
||||
colorScheme: colorScheme,
|
||||
isPlaying: _isPlaying,
|
||||
currentPosition: _currentPosition,
|
||||
totalDuration: _totalDuration,
|
||||
bufferedRanges: _bufferedRanges,
|
||||
playbackSpeed: _playbackSpeed,
|
||||
onPlayPause: _togglePlayPause,
|
||||
onSeek: (position) {
|
||||
setState(() {
|
||||
_isDragging = true;
|
||||
_currentPosition = position;
|
||||
});
|
||||
},
|
||||
onSeekEnd: (position) {
|
||||
_seekTo(position);
|
||||
},
|
||||
onBack: () => Navigator.pop(context),
|
||||
onSpeedTap: () {
|
||||
_showSpeedMenu();
|
||||
},
|
||||
onRewind: () {
|
||||
final newPosition = _clampDuration(
|
||||
_currentPosition - const Duration(seconds: 10),
|
||||
Duration.zero,
|
||||
_totalDuration,
|
||||
);
|
||||
_seekTo(newPosition);
|
||||
_showControlsUI();
|
||||
},
|
||||
onForward: () {
|
||||
final newPosition = _clampDuration(
|
||||
_currentPosition + const Duration(seconds: 10),
|
||||
Duration.zero,
|
||||
_totalDuration,
|
||||
);
|
||||
_seekTo(newPosition);
|
||||
_showControlsUI();
|
||||
},
|
||||
formatDuration: _formatDuration,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VideoControls extends StatelessWidget {
|
||||
final ColorScheme colorScheme;
|
||||
final bool isPlaying;
|
||||
final Duration currentPosition;
|
||||
final Duration totalDuration;
|
||||
final List<DurationRange> bufferedRanges;
|
||||
final double playbackSpeed;
|
||||
final VoidCallback onPlayPause;
|
||||
final Function(Duration) onSeek;
|
||||
final Function(Duration) onSeekEnd;
|
||||
final VoidCallback onBack;
|
||||
final VoidCallback onSpeedTap;
|
||||
final VoidCallback onRewind;
|
||||
final VoidCallback onForward;
|
||||
final String Function(Duration) formatDuration;
|
||||
|
||||
const _VideoControls({
|
||||
required this.colorScheme,
|
||||
required this.isPlaying,
|
||||
required this.currentPosition,
|
||||
required this.totalDuration,
|
||||
required this.bufferedRanges,
|
||||
required this.playbackSpeed,
|
||||
required this.onPlayPause,
|
||||
required this.onSeek,
|
||||
required this.onSeekEnd,
|
||||
required this.onBack,
|
||||
required this.onSpeedTap,
|
||||
required this.onRewind,
|
||||
required this.onForward,
|
||||
required this.formatDuration,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final progress = totalDuration.inMilliseconds > 0
|
||||
? currentPosition.inMilliseconds / totalDuration.inMilliseconds
|
||||
: 0.0;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.black.withOpacity(0.7),
|
||||
Colors.transparent,
|
||||
Colors.transparent,
|
||||
Colors.black.withOpacity(0.7),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: onBack,
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: Colors.black.withOpacity(0.5),
|
||||
foregroundColor: Colors.white,
|
||||
shape: const CircleBorder(),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton.tonal(
|
||||
onPressed: onSpeedTap,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.black.withOpacity(0.5),
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.speed, size: 18),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'${playbackSpeed}x',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
|
||||
// Прогресс-бар
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
_CustomProgressBar(
|
||||
progress: progress,
|
||||
currentPosition: currentPosition,
|
||||
totalDuration: totalDuration,
|
||||
bufferedRanges: bufferedRanges,
|
||||
onSeek: onSeek,
|
||||
onSeekEnd: onSeekEnd,
|
||||
colorScheme: colorScheme,
|
||||
formatDuration: formatDuration,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
formatDuration(currentPosition),
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
' / ',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
formatDuration(totalDuration),
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_MaterialYouControlButton(
|
||||
icon: Icons.replay_10,
|
||||
onTap: onRewind,
|
||||
colorScheme: colorScheme,
|
||||
label: '-10',
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_MaterialYouControlButton(
|
||||
icon: isPlaying ? Icons.pause : Icons.play_arrow,
|
||||
onTap: onPlayPause,
|
||||
colorScheme: colorScheme,
|
||||
isPrimary: true,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Кнопка перемотки вперед
|
||||
_MaterialYouControlButton(
|
||||
icon: Icons.forward_10,
|
||||
onTap: onForward,
|
||||
colorScheme: colorScheme,
|
||||
label: '+10',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MaterialYouControlButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
final ColorScheme colorScheme;
|
||||
final String? label;
|
||||
final bool isPrimary;
|
||||
|
||||
const _MaterialYouControlButton({
|
||||
required this.icon,
|
||||
required this.onTap,
|
||||
required this.colorScheme,
|
||||
this.label,
|
||||
this.isPrimary = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isPrimary) {
|
||||
return FilledButton(
|
||||
onPressed: onTap,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
padding: const EdgeInsets.all(20),
|
||||
shape: const CircleBorder(),
|
||||
minimumSize: const Size(72, 72),
|
||||
elevation: 3,
|
||||
),
|
||||
child: Icon(icon, size: 36),
|
||||
);
|
||||
} else {
|
||||
return FilledButton.tonal(
|
||||
onPressed: onTap,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.white.withOpacity(0.16),
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.all(14),
|
||||
shape: const CircleBorder(),
|
||||
minimumSize: const Size(60, 60),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 26),
|
||||
if (label != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
label!,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.0,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _CustomProgressBar extends StatefulWidget {
|
||||
final double progress;
|
||||
final Duration currentPosition;
|
||||
final Duration totalDuration;
|
||||
final List<DurationRange> bufferedRanges;
|
||||
final Function(Duration) onSeek;
|
||||
final Function(Duration) onSeekEnd;
|
||||
final ColorScheme colorScheme;
|
||||
final String Function(Duration) formatDuration;
|
||||
|
||||
const _CustomProgressBar({
|
||||
required this.progress,
|
||||
required this.currentPosition,
|
||||
required this.totalDuration,
|
||||
required this.bufferedRanges,
|
||||
required this.onSeek,
|
||||
required this.onSeekEnd,
|
||||
required this.colorScheme,
|
||||
required this.formatDuration,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_CustomProgressBar> createState() => _CustomProgressBarState();
|
||||
}
|
||||
|
||||
class _CustomProgressBarState extends State<_CustomProgressBar> {
|
||||
bool _isDragging = false;
|
||||
double _dragProgress = 0.0;
|
||||
|
||||
Duration _getPositionFromLocalPosition(Offset localPosition, Size size) {
|
||||
final progress = (localPosition.dx / size.width).clamp(0.0, 1.0);
|
||||
return Duration(
|
||||
milliseconds: (progress * widget.totalDuration.inMilliseconds).round(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final progress = _isDragging ? _dragProgress : widget.progress;
|
||||
final currentPos = Duration(
|
||||
milliseconds: (progress * widget.totalDuration.inMilliseconds).round(),
|
||||
);
|
||||
|
||||
return GestureDetector(
|
||||
onPanStart: (details) {
|
||||
setState(() {
|
||||
_isDragging = true;
|
||||
});
|
||||
final box = context.findRenderObject() as RenderBox;
|
||||
final localPosition = box.globalToLocal(details.globalPosition);
|
||||
_dragProgress = (localPosition.dx / box.size.width).clamp(0.0, 1.0);
|
||||
final position = _getPositionFromLocalPosition(localPosition, box.size);
|
||||
widget.onSeek(position);
|
||||
},
|
||||
onPanUpdate: (details) {
|
||||
final box = context.findRenderObject() as RenderBox;
|
||||
final localPosition = box.globalToLocal(details.globalPosition);
|
||||
setState(() {
|
||||
_dragProgress = (localPosition.dx / box.size.width).clamp(0.0, 1.0);
|
||||
});
|
||||
final position = _getPositionFromLocalPosition(localPosition, box.size);
|
||||
widget.onSeek(position);
|
||||
},
|
||||
onPanEnd: (details) {
|
||||
setState(() {
|
||||
_isDragging = false;
|
||||
});
|
||||
widget.onSeekEnd(currentPos);
|
||||
},
|
||||
onTapDown: (details) {
|
||||
if (_isDragging) return;
|
||||
final box = context.findRenderObject() as RenderBox;
|
||||
final localPosition = box.globalToLocal(details.globalPosition);
|
||||
final position = _getPositionFromLocalPosition(localPosition, box.size);
|
||||
widget.onSeekEnd(position);
|
||||
},
|
||||
child: Container(
|
||||
height: 48,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final containerWidth = constraints.maxWidth;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.totalDuration.inMilliseconds > 0)
|
||||
...widget.bufferedRanges.map((range) {
|
||||
final startProgress = (range.start.inMilliseconds / widget.totalDuration.inMilliseconds).clamp(0.0, 1.0);
|
||||
final endProgress = (range.end.inMilliseconds / widget.totalDuration.inMilliseconds).clamp(0.0, 1.0);
|
||||
final bufferedWidth = (endProgress - startProgress).clamp(0.0, 1.0);
|
||||
|
||||
if (bufferedWidth <= 0) return const SizedBox.shrink();
|
||||
|
||||
final leftOffset = startProgress * containerWidth;
|
||||
final bufferedWidthPx = bufferedWidth * containerWidth;
|
||||
|
||||
return Positioned(
|
||||
left: leftOffset,
|
||||
top: 22,
|
||||
child: Container(
|
||||
width: bufferedWidthPx,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
Center(
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: progress,
|
||||
child: Container(
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Ползунок
|
||||
Center(
|
||||
child: Align(
|
||||
alignment: Alignment(progress * 2 - 1, 0),
|
||||
child: Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SpeedBottomSheet extends StatelessWidget {
|
||||
final double currentSpeed;
|
||||
final Function(double) onSpeedSelected;
|
||||
|
||||
const _SpeedBottomSheet({
|
||||
required this.currentSpeed,
|
||||
required this.onSpeedSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final speeds = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0];
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.dialogBackgroundColor,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[400],
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'Скорость воспроизведения',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
alignment: WrapAlignment.center,
|
||||
children: speeds.map((speed) {
|
||||
final isSelected = speed == currentSpeed;
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () => onSpeedSelected(speed),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'${speed}x',
|
||||
style: TextStyle(
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
fontSize: 16,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorWidget extends StatelessWidget {
|
||||
final ColorScheme colorScheme;
|
||||
|
||||
const _ErrorWidget({required this.colorScheme});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.errorContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.error_outline,
|
||||
color: colorScheme.onErrorContainer,
|
||||
size: 48,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Не удалось загрузить видео',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Проверьте подключение к интернету\nили попробуйте позже',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
289
lib/utils/image_cache_service.dart
Normal file
289
lib/utils/image_cache_service.dart
Normal file
@@ -0,0 +1,289 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:es_compression/lz4.dart';
|
||||
|
||||
class ImageCacheService {
|
||||
ImageCacheService._privateConstructor();
|
||||
static final ImageCacheService instance =
|
||||
ImageCacheService._privateConstructor();
|
||||
|
||||
static const String _cacheDirectoryName = 'image_cache';
|
||||
static const Duration _cacheExpiration = Duration(
|
||||
days: 7,
|
||||
); // Кеш изображений на 7 дней
|
||||
late Directory _cacheDirectory;
|
||||
|
||||
// LZ4 сжатие для экономии места
|
||||
final Lz4Codec _lz4Codec = Lz4Codec();
|
||||
|
||||
Future<void> initialize() async {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
_cacheDirectory = Directory(path.join(appDir.path, _cacheDirectoryName));
|
||||
|
||||
if (!_cacheDirectory.existsSync()) {
|
||||
await _cacheDirectory.create(recursive: true);
|
||||
}
|
||||
|
||||
await _cleanupExpiredCache();
|
||||
}
|
||||
|
||||
String getCachedImagePath(String url) {
|
||||
final fileName = _generateFileName(url);
|
||||
return path.join(_cacheDirectory.path, fileName);
|
||||
}
|
||||
|
||||
bool isImageCached(String url) {
|
||||
final file = File(getCachedImagePath(url));
|
||||
return file.existsSync();
|
||||
}
|
||||
|
||||
Future<File?> loadImage(String url, {bool forceRefresh = false}) async {
|
||||
if (!forceRefresh && isImageCached(url)) {
|
||||
final cachedFile = File(getCachedImagePath(url));
|
||||
if (await _isFileValid(cachedFile)) {
|
||||
return cachedFile;
|
||||
} else {
|
||||
await cachedFile.delete();
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await http.get(Uri.parse(url));
|
||||
if (response.statusCode == 200) {
|
||||
final file = File(getCachedImagePath(url));
|
||||
// Сжимаем данные перед сохранением
|
||||
final compressedData = _lz4Codec.encode(response.bodyBytes);
|
||||
await file.writeAsBytes(compressedData);
|
||||
|
||||
await _updateFileAccessTime(file);
|
||||
|
||||
return file;
|
||||
}
|
||||
} catch (e) {
|
||||
print('Ошибка загрузки изображения $url: $e');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<Uint8List?> loadImageAsBytes(
|
||||
String url, {
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
final file = await loadImage(url, forceRefresh: forceRefresh);
|
||||
if (file != null) {
|
||||
final compressedData = await file.readAsBytes();
|
||||
try {
|
||||
// Декомпрессируем данные
|
||||
final decompressedData = _lz4Codec.decode(compressedData);
|
||||
return Uint8List.fromList(decompressedData);
|
||||
} catch (e) {
|
||||
// Если декомпрессия не удалась, возможно файл не сжат (старый формат)
|
||||
print(
|
||||
'Ошибка декомпрессии изображения $url, пробуем прочитать как обычный файл: $e',
|
||||
);
|
||||
return compressedData;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> preloadImage(String url) async {
|
||||
await loadImage(url);
|
||||
}
|
||||
|
||||
Future<void> preloadContactAvatar(String? photoUrl) async {
|
||||
if (photoUrl != null && photoUrl.isNotEmpty) {
|
||||
await preloadImage(photoUrl);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> preloadProfileAvatar(String? photoUrl) async {
|
||||
if (photoUrl != null && photoUrl.isNotEmpty) {
|
||||
await preloadImage(photoUrl);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> preloadContactAvatars(List<String?> photoUrls) async {
|
||||
final futures = photoUrls
|
||||
.where((url) => url != null && url.isNotEmpty)
|
||||
.map((url) => preloadImage(url!))
|
||||
.toList();
|
||||
|
||||
if (futures.isNotEmpty) {
|
||||
await Future.wait(futures);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearCache() async {
|
||||
if (_cacheDirectory.existsSync()) {
|
||||
await _clearDirectoryContents(_cacheDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearDirectoryContents(Directory directory) async {
|
||||
try {
|
||||
// Очищаем содержимое директории, удаляя файлы по одному
|
||||
await for (final entity in directory.list(recursive: true)) {
|
||||
if (entity is File) {
|
||||
try {
|
||||
await entity.delete();
|
||||
// Небольшая задержка между удалениями для избежания конфликтов
|
||||
await Future.delayed(const Duration(milliseconds: 5));
|
||||
} catch (fileError) {
|
||||
// Игнорируем ошибки удаления отдельных файлов
|
||||
print('Не удалось удалить файл ${entity.path}: $fileError');
|
||||
}
|
||||
} else if (entity is Directory) {
|
||||
try {
|
||||
// Рекурсивно очищаем поддиректории
|
||||
await _clearDirectoryContents(entity);
|
||||
try {
|
||||
await entity.delete();
|
||||
} catch (dirError) {
|
||||
print(
|
||||
'Не удалось удалить поддиректорию ${entity.path}: $dirError',
|
||||
);
|
||||
}
|
||||
} catch (subDirError) {
|
||||
print('Ошибка очистки поддиректории ${entity.path}: $subDirError');
|
||||
}
|
||||
}
|
||||
}
|
||||
print('Содержимое директории ${directory.path} очищено');
|
||||
} catch (e) {
|
||||
print('Ошибка очистки содержимого директории ${directory.path}: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> getCacheSize() async {
|
||||
int totalSize = 0;
|
||||
if (_cacheDirectory.existsSync()) {
|
||||
await for (final entity in _cacheDirectory.list(recursive: true)) {
|
||||
if (entity is File) {
|
||||
totalSize += await entity.length();
|
||||
}
|
||||
}
|
||||
}
|
||||
return totalSize;
|
||||
}
|
||||
|
||||
Future<int> getCacheFileCount() async {
|
||||
int count = 0;
|
||||
if (_cacheDirectory.existsSync()) {
|
||||
await for (final entity in _cacheDirectory.list(recursive: true)) {
|
||||
if (entity is File) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
Future<void> _cleanupExpiredCache() async {
|
||||
if (!_cacheDirectory.existsSync()) return;
|
||||
|
||||
await for (final entity in _cacheDirectory.list(recursive: true)) {
|
||||
if (entity is File && await _isFileExpired(entity)) {
|
||||
await entity.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _isFileValid(File file) async {
|
||||
if (!file.existsSync()) return false;
|
||||
|
||||
final stat = await file.stat();
|
||||
final age = DateTime.now().difference(stat.modified);
|
||||
|
||||
return age < _cacheExpiration;
|
||||
}
|
||||
|
||||
Future<bool> _isFileExpired(File file) async {
|
||||
if (!file.existsSync()) return false;
|
||||
|
||||
final stat = await file.stat();
|
||||
final age = DateTime.now().difference(stat.modified);
|
||||
|
||||
return age >= _cacheExpiration;
|
||||
}
|
||||
|
||||
Future<void> _updateFileAccessTime(File file) async {
|
||||
try {
|
||||
await file.setLastModified(DateTime.now());
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
String _generateFileName(String url) {
|
||||
final hash = url.hashCode.abs().toString();
|
||||
final extension = path.extension(url).isNotEmpty
|
||||
? path.extension(url)
|
||||
: '.jpg'; // По умолчанию jpg
|
||||
|
||||
return '$hash$extension';
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getCacheStats() async {
|
||||
final size = await getCacheSize();
|
||||
final fileCount = await getCacheFileCount();
|
||||
|
||||
return {
|
||||
'cache_size_bytes': size,
|
||||
'cache_size_mb': (size / (1024 * 1024)).toStringAsFixed(2),
|
||||
'file_count': fileCount,
|
||||
'cache_directory': _cacheDirectory.path,
|
||||
'compression_enabled': true,
|
||||
'compression_algorithm': 'LZ4',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
extension CachedImageExtension on String {
|
||||
Widget getCachedNetworkImage({
|
||||
Key? key,
|
||||
double? width,
|
||||
double? height,
|
||||
BoxFit? fit,
|
||||
Widget? placeholder,
|
||||
Widget? errorWidget,
|
||||
Duration? fadeInDuration,
|
||||
bool useMemoryCache = true,
|
||||
}) {
|
||||
return CachedNetworkImage(
|
||||
key: key,
|
||||
imageUrl: this,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
placeholder: (context, url) =>
|
||||
placeholder ??
|
||||
Container(
|
||||
width: width,
|
||||
height: height,
|
||||
color: Colors.grey[300],
|
||||
child: const Icon(Icons.image, color: Colors.grey),
|
||||
),
|
||||
errorWidget: (context, url, error) =>
|
||||
errorWidget ??
|
||||
Container(
|
||||
width: width,
|
||||
height: height,
|
||||
color: Colors.grey[300],
|
||||
child: const Icon(Icons.broken_image, color: Colors.grey),
|
||||
),
|
||||
fadeInDuration: fadeInDuration ?? const Duration(milliseconds: 300),
|
||||
useOldImageOnUrlChange: true,
|
||||
memCacheWidth: useMemoryCache ? (width ?? 200).toInt() : null,
|
||||
memCacheHeight: useMemoryCache ? (height ?? 200).toInt() : null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> preloadImage() async {
|
||||
await ImageCacheService.instance.loadImage(this);
|
||||
}
|
||||
}
|
||||
98
lib/utils/packet_framer.dart
Normal file
98
lib/utils/packet_framer.dart
Normal file
@@ -0,0 +1,98 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:msgpack_dart/msgpack_dart.dart';
|
||||
import 'package:es_compression/lz4.dart';
|
||||
|
||||
final lz4Codec = Lz4Codec();
|
||||
|
||||
Uint8List packPacket({
|
||||
required int ver,
|
||||
required int cmd,
|
||||
required int seq,
|
||||
required int opcode,
|
||||
required Map<String, dynamic> payload,
|
||||
}) {
|
||||
Uint8List payloadBytes = serialize(payload);
|
||||
bool isCompressed = false;
|
||||
|
||||
if (payloadBytes.length >= 32) {
|
||||
final uncompressedSize = ByteData(4)
|
||||
..setUint32(0, payloadBytes.length, Endian.big);
|
||||
|
||||
final compressedData = lz4Codec.encode(payloadBytes);
|
||||
|
||||
final builder = BytesBuilder();
|
||||
builder.add(uncompressedSize.buffer.asUint8List());
|
||||
builder.add(compressedData);
|
||||
payloadBytes = builder.toBytes();
|
||||
isCompressed = true;
|
||||
}
|
||||
|
||||
final header = ByteData(10);
|
||||
header.setUint8(0, ver);
|
||||
header.setUint16(1, cmd, Endian.big);
|
||||
header.setUint8(3, seq);
|
||||
header.setUint16(4, opcode, Endian.big);
|
||||
|
||||
int packedLen = payloadBytes.length;
|
||||
if (isCompressed) {
|
||||
packedLen |= (1 << 24);
|
||||
}
|
||||
header.setUint32(6, packedLen, Endian.big);
|
||||
|
||||
final builder = BytesBuilder();
|
||||
builder.add(header.buffer.asUint8List());
|
||||
builder.add(payloadBytes);
|
||||
|
||||
return builder.toBytes();
|
||||
}
|
||||
|
||||
Map<String, dynamic>? unpackPacket(Uint8List data) {
|
||||
if (data.length < 10) {
|
||||
print("Ошибка распаковки: Пакет слишком короткий для заголовка.");
|
||||
return null;
|
||||
}
|
||||
|
||||
final byteData = data.buffer.asByteData(
|
||||
data.offsetInBytes,
|
||||
data.lengthInBytes,
|
||||
);
|
||||
|
||||
final ver = byteData.getUint8(0);
|
||||
final cmd = byteData.getUint16(1, Endian.big);
|
||||
final seq = byteData.getUint8(3);
|
||||
final opcode = byteData.getUint16(4, Endian.big);
|
||||
final packedLen = byteData.getUint32(6, Endian.big);
|
||||
|
||||
final compFlag = packedLen >> 24;
|
||||
final payloadLength = packedLen & 0x00FFFFFF;
|
||||
|
||||
if (data.length < 10 + payloadLength) {
|
||||
print(
|
||||
"Ошибка распаковки: Фактическая длина пакета (${data.length}) меньше заявленной (${10 + payloadLength}).",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
Uint8List payloadBytes = data.sublist(10, 10 + payloadLength);
|
||||
|
||||
if (compFlag != 0) {
|
||||
try {
|
||||
final compressedData = payloadBytes.sublist(4);
|
||||
|
||||
payloadBytes = Uint8List.fromList(lz4Codec.decode(compressedData));
|
||||
} catch (e) {
|
||||
print("Ошибка распаковки LZ4: $e");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final dynamic payload = deserialize(payloadBytes);
|
||||
|
||||
return {
|
||||
"ver": ver,
|
||||
"cmd": cmd,
|
||||
"seq": seq,
|
||||
"opcode": opcode,
|
||||
"payload": payload,
|
||||
};
|
||||
}
|
||||
158
lib/utils/proxy_service.dart
Normal file
158
lib/utils/proxy_service.dart
Normal file
@@ -0,0 +1,158 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'proxy_settings.dart';
|
||||
|
||||
class ProxyService {
|
||||
ProxyService._privateConstructor();
|
||||
static final ProxyService instance = ProxyService._privateConstructor();
|
||||
|
||||
static const _proxySettingsKey = 'proxy_settings';
|
||||
|
||||
Future<void> saveProxySettings(ProxySettings settings) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = jsonEncode(settings.toJson());
|
||||
await prefs.setString(_proxySettingsKey, jsonString);
|
||||
}
|
||||
|
||||
Future<ProxySettings> loadProxySettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(_proxySettingsKey);
|
||||
if (jsonString != null) {
|
||||
try {
|
||||
return ProxySettings.fromJson(jsonDecode(jsonString));
|
||||
} catch (e) {
|
||||
return ProxySettings();
|
||||
}
|
||||
}
|
||||
return ProxySettings();
|
||||
}
|
||||
|
||||
Future<void> checkProxy(ProxySettings settings) async {
|
||||
print("Проверка прокси: ${settings.host}:${settings.port}");
|
||||
|
||||
if (settings.protocol == ProxyProtocol.socks5) {
|
||||
await _checkSocks5Proxy(settings);
|
||||
return;
|
||||
}
|
||||
|
||||
HttpClient client = _createClientWithOptions(settings);
|
||||
|
||||
client.connectionTimeout = const Duration(seconds: 10);
|
||||
|
||||
try {
|
||||
final request = await client.headUrl(
|
||||
Uri.parse('https://www.google.com/generate_204'),
|
||||
);
|
||||
final response = await request.close();
|
||||
|
||||
print("Ответ от прокси получен, статус: ${response.statusCode}");
|
||||
|
||||
if (response.statusCode >= 400) {
|
||||
throw Exception('Прокси вернул ошибку: ${response.statusCode}');
|
||||
}
|
||||
} on HandshakeException catch (e) {
|
||||
print("Поймана ошибка сертификата при проверке прокси: $e");
|
||||
print(
|
||||
"Предполагаем, что badCertificateCallback обработает это в реальном соединении. Считаем проверку успешной.",
|
||||
);
|
||||
|
||||
return;
|
||||
} on SocketException catch (e) {
|
||||
print("Ошибка сокета при проверке прокси: $e");
|
||||
throw Exception('Неверный хост или порт');
|
||||
} on TimeoutException catch (_) {
|
||||
print("Таймаут при проверке прокси");
|
||||
throw Exception('Сервер не отвечает (таймаут)');
|
||||
} catch (e) {
|
||||
print("Неизвестная ошибка при проверке прокси: $e");
|
||||
throw Exception('Неизвестная ошибка: ${e.toString()}');
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkSocks5Proxy(ProxySettings settings) async {
|
||||
Socket? proxySocket;
|
||||
try {
|
||||
print("Проверка SOCKS5 прокси: ${settings.host}:${settings.port}");
|
||||
|
||||
proxySocket = await Socket.connect(
|
||||
settings.host,
|
||||
settings.port,
|
||||
timeout: const Duration(seconds: 10),
|
||||
);
|
||||
|
||||
print("SOCKS5 прокси доступен: ${settings.host}:${settings.port}");
|
||||
print(
|
||||
"Внимание: Полная проверка SOCKS5 требует дополнительной реализации",
|
||||
);
|
||||
|
||||
// Закрываем соединение
|
||||
await proxySocket.close();
|
||||
print("SOCKS5 прокси работает корректно");
|
||||
} on SocketException catch (e) {
|
||||
print("Ошибка сокета при проверке SOCKS5 прокси: $e");
|
||||
throw Exception('Неверный хост или порт');
|
||||
} on TimeoutException catch (_) {
|
||||
print("Таймаут при проверке SOCKS5 прокси");
|
||||
throw Exception('Сервер не отвечает (таймаут)');
|
||||
} catch (e) {
|
||||
print("Ошибка при проверке SOCKS5 прокси: $e");
|
||||
throw Exception('Ошибка подключения: ${e.toString()}');
|
||||
} finally {
|
||||
await proxySocket?.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<HttpClient> getHttpClientWithProxy() async {
|
||||
final settings = await loadProxySettings();
|
||||
return _createClientWithOptions(settings);
|
||||
}
|
||||
|
||||
HttpClient _createClientWithOptions(ProxySettings settings) {
|
||||
final client = HttpClient();
|
||||
|
||||
if (settings.isEnabled && settings.host.isNotEmpty) {
|
||||
if (settings.protocol == ProxyProtocol.socks5) {
|
||||
print("Используется SOCKS5 прокси: ${settings.host}:${settings.port}");
|
||||
print("Внимание: SOCKS5 для HTTP клиента может работать ограниченно");
|
||||
client.findProxy = (uri) {
|
||||
return settings.toFindProxyString();
|
||||
};
|
||||
} else {
|
||||
print("Используется прокси: ${settings.toFindProxyString()}");
|
||||
|
||||
client.findProxy = (uri) {
|
||||
return settings.toFindProxyString();
|
||||
};
|
||||
|
||||
if (settings.username != null && settings.username!.isNotEmpty) {
|
||||
print(
|
||||
"Настраивается аутентификация на прокси для пользователя: ${settings.username}",
|
||||
);
|
||||
client.authenticateProxy = (host, port, scheme, realm) async {
|
||||
client.addProxyCredentials(
|
||||
host,
|
||||
port,
|
||||
realm ?? '',
|
||||
HttpClientBasicCredentials(
|
||||
settings.username!,
|
||||
settings.password ?? '',
|
||||
),
|
||||
);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
client.badCertificateCallback =
|
||||
(X509Certificate cert, String host, int port) => true;
|
||||
} else {
|
||||
client.findProxy = HttpClient.findProxyFromEnvironment;
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
}
|
||||
91
lib/utils/proxy_settings.dart
Normal file
91
lib/utils/proxy_settings.dart
Normal file
@@ -0,0 +1,91 @@
|
||||
|
||||
|
||||
enum ProxyProtocol { http, https, socks4, socks5 }
|
||||
|
||||
class ProxySettings {
|
||||
final bool isEnabled;
|
||||
final String host;
|
||||
final int port;
|
||||
final ProxyProtocol protocol;
|
||||
final String? username;
|
||||
final String? password;
|
||||
|
||||
ProxySettings({
|
||||
this.isEnabled = false,
|
||||
this.host = '',
|
||||
this.port = 8080,
|
||||
this.protocol = ProxyProtocol.http,
|
||||
this.username,
|
||||
this.password,
|
||||
});
|
||||
|
||||
|
||||
String toFindProxyString() {
|
||||
if (!isEnabled || host.isEmpty) {
|
||||
return 'DIRECT';
|
||||
}
|
||||
|
||||
|
||||
String protocolString;
|
||||
switch (protocol) {
|
||||
case ProxyProtocol.http:
|
||||
case ProxyProtocol.https:
|
||||
protocolString = 'PROXY'; // HttpClient ожидает 'PROXY' для HTTP и HTTPS
|
||||
break;
|
||||
case ProxyProtocol.socks4:
|
||||
|
||||
|
||||
protocolString = 'SOCKS4';
|
||||
break;
|
||||
case ProxyProtocol.socks5:
|
||||
protocolString = 'SOCKS5';
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
return '$protocolString $host:$port';
|
||||
}
|
||||
|
||||
ProxySettings copyWith({
|
||||
bool? isEnabled,
|
||||
String? host,
|
||||
int? port,
|
||||
ProxyProtocol? protocol,
|
||||
String? username,
|
||||
String? password,
|
||||
}) {
|
||||
return ProxySettings(
|
||||
isEnabled: isEnabled ?? this.isEnabled,
|
||||
host: host ?? this.host,
|
||||
port: port ?? this.port,
|
||||
protocol: protocol ?? this.protocol,
|
||||
username: username ?? this.username,
|
||||
password: password ?? this.password,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'isEnabled': isEnabled,
|
||||
'host': host,
|
||||
'port': port,
|
||||
'protocol': protocol.name,
|
||||
'username': username,
|
||||
'password': password,
|
||||
};
|
||||
}
|
||||
|
||||
factory ProxySettings.fromJson(Map<String, dynamic> json) {
|
||||
return ProxySettings(
|
||||
isEnabled: json['isEnabled'] ?? false,
|
||||
host: json['host'] ?? '',
|
||||
port: json['port'] ?? 8080,
|
||||
protocol: ProxyProtocol.values.firstWhere(
|
||||
(e) => e.name == json['protocol'],
|
||||
orElse: () => ProxyProtocol.http,
|
||||
),
|
||||
username: json['username'],
|
||||
password: json['password'],
|
||||
);
|
||||
}
|
||||
}
|
||||
28
lib/utils/spoofing_service.dart
Normal file
28
lib/utils/spoofing_service.dart
Normal file
@@ -0,0 +1,28 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class SpoofingService {
|
||||
|
||||
|
||||
static Future<Map<String, dynamic>?> getSpoofedSessionData() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
final isEnabled = prefs.getBool('spoofing_enabled') ?? false;
|
||||
|
||||
if (!isEnabled) {
|
||||
return null; // Если подмена выключена, возвращаем null
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
'user_agent': prefs.getString('spoof_useragent'),
|
||||
'device_name': prefs.getString('spoof_devicename'),
|
||||
'os_version': prefs.getString('spoof_osversion'),
|
||||
'screen': prefs.getString('spoof_screen'),
|
||||
'timezone': prefs.getString('spoof_timezone'),
|
||||
'locale': prefs.getString('spoof_locale'),
|
||||
'device_id': prefs.getString('spoof_deviceid'),
|
||||
'device_type': prefs.getString('spoof_devicetype'),
|
||||
'app_version': prefs.getString('spoof_appversion') ?? '25.10.10',
|
||||
};
|
||||
}
|
||||
}
|
||||
1250
lib/utils/theme_provider.dart
Normal file
1250
lib/utils/theme_provider.dart
Normal file
File diff suppressed because it is too large
Load Diff
6
lib/utils/universal_io.dart
Normal file
6
lib/utils/universal_io.dart
Normal file
@@ -0,0 +1,6 @@
|
||||
library;
|
||||
|
||||
class Platform {
|
||||
static bool get isAndroid => false;
|
||||
static bool get isIOS => false;
|
||||
}
|
||||
263
lib/utils/user_id_lookup_screen.dart
Normal file
263
lib/utils/user_id_lookup_screen.dart
Normal file
@@ -0,0 +1,263 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:gwid/api/api_service.dart';
|
||||
import 'package:gwid/models/contact.dart';
|
||||
|
||||
class UserIdLookupScreen extends StatefulWidget {
|
||||
const UserIdLookupScreen({super.key});
|
||||
|
||||
@override
|
||||
State<UserIdLookupScreen> createState() => _UserIdLookupScreenState();
|
||||
}
|
||||
|
||||
class _UserIdLookupScreenState extends State<UserIdLookupScreen> {
|
||||
final TextEditingController _idController = TextEditingController();
|
||||
final FocusNode _idFocusNode = FocusNode();
|
||||
bool _isLoading = false;
|
||||
Contact? _foundContact;
|
||||
bool _searchAttempted = false;
|
||||
|
||||
Future<void> _searchById() async {
|
||||
final String idText = _idController.text.trim();
|
||||
if (idText.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final int? contactId = int.tryParse(idText);
|
||||
if (contactId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Пожалуйста, введите корректный ID (только цифры)'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_idFocusNode.unfocus();
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_searchAttempted = true;
|
||||
_foundContact = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final List<Contact> contacts = await ApiService.instance
|
||||
.fetchContactsByIds([contactId]);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_foundContact = contacts.isNotEmpty ? contacts.first : null;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Ошибка при поиске: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_idFocusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Поиск по ID')),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: _idController,
|
||||
focusNode: _idFocusNode,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Введите ID пользователя',
|
||||
filled: true,
|
||||
fillColor: colors.surfaceContainerHighest,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
prefixIcon: const Icon(Icons.person_search_outlined),
|
||||
suffixIcon: _isLoading
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(12.0),
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
onPressed: _searchById,
|
||||
),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
onSubmitted: (_) => _searchById(),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: _isLoading
|
||||
? const Center(
|
||||
key: ValueKey('loading'),
|
||||
child: CircularProgressIndicator(),
|
||||
)
|
||||
: _searchAttempted
|
||||
? _foundContact != null
|
||||
? _buildContactCard(_foundContact!, colors)
|
||||
: _buildEmptyState(
|
||||
key: const ValueKey('not_found'),
|
||||
colors: colors,
|
||||
icon: Icons.search_off_rounded,
|
||||
title: 'Пользователь не найден',
|
||||
subtitle:
|
||||
'Аккаунт с ID "${_idController.text}" не существует или скрыт.',
|
||||
)
|
||||
: _buildEmptyState(
|
||||
key: const ValueKey('initial'),
|
||||
colors: colors,
|
||||
icon: Icons.person_search_rounded,
|
||||
title: 'Введите ID для поиска',
|
||||
subtitle: 'Найдем пользователя в системе по его ID',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContactCard(Contact contact, ColorScheme colors) {
|
||||
return Column(
|
||||
key: const ValueKey('contact_card'),
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 56,
|
||||
backgroundColor: colors.primaryContainer,
|
||||
backgroundImage: contact.photoBaseUrl != null
|
||||
? NetworkImage(contact.photoBaseUrl!)
|
||||
: null,
|
||||
child: contact.photoBaseUrl == null
|
||||
? Text(
|
||||
contact.name.isNotEmpty ? contact.name[0].toUpperCase() : '?',
|
||||
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
|
||||
color: colors.onPrimaryContainer,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
contact.name,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'ID: ${contact.id}',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(color: colors.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildInfoTile(
|
||||
colors: colors,
|
||||
icon: Icons.person_outlined,
|
||||
title: 'Имя',
|
||||
subtitle: contact.firstName,
|
||||
),
|
||||
const Divider(height: 1, indent: 16, endIndent: 16),
|
||||
_buildInfoTile(
|
||||
colors: colors,
|
||||
icon: Icons.badge_outlined,
|
||||
title: 'Фамилия',
|
||||
subtitle: contact.lastName,
|
||||
),
|
||||
const Divider(height: 1, indent: 16, endIndent: 16),
|
||||
_buildInfoTile(
|
||||
colors: colors,
|
||||
icon: Icons.notes_rounded,
|
||||
title: 'Описание',
|
||||
subtitle: contact.description,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoTile({
|
||||
required ColorScheme colors,
|
||||
required IconData icon,
|
||||
required String title,
|
||||
String? subtitle,
|
||||
}) {
|
||||
final bool hasData = subtitle != null && subtitle.isNotEmpty;
|
||||
|
||||
return ListTile(
|
||||
leading: Icon(icon, color: colors.primary),
|
||||
title: Text(title),
|
||||
subtitle: Text(
|
||||
hasData ? subtitle : '(не указано)',
|
||||
style: TextStyle(
|
||||
color: hasData
|
||||
? colors.onSurfaceVariant
|
||||
: colors.onSurfaceVariant.withOpacity(0.7),
|
||||
fontStyle: hasData ? FontStyle.normal : FontStyle.italic,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState({
|
||||
required Key key,
|
||||
required ColorScheme colors,
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
}) {
|
||||
return Column(
|
||||
key: key,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 64, color: colors.onSurfaceVariant.withOpacity(0.5)),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(color: colors.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
subtitle,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onSurfaceVariant.withOpacity(0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user