diff --git a/design/komet-design-system.svg b/design/komet-design-system.svg new file mode 100644 index 0000000..dcca5f3 --- /dev/null +++ b/design/komet-design-system.svg @@ -0,0 +1,320 @@ + + + + + + + + + + Komet — Design System + Seed #C1C4FF · Material 3 Expressive · Inter + + + + Цвета — Light + + + + + primary + #4D55A9 + + + + primaryContainer + #DFE0FF + + + + surface + #FEFBFF + + + + surfaceContainer + #F2EEF8 + + + + surfaceContainerHigh + #ECE8F2 + + + + surfaceContainerHighest + #E6E2EC + + + + + onSurface + #1B1B21 + + + + onSurfaceVariant + #46464F + + + + outline + #767680 + + + + outlineVariant + #C6C5D0 + + + + error + #BA1A1A + + + + errorContainer + #FFDAD6 + + + + + + Цвета — Dark + + + + + + primary + #BCC2FF + + + + primaryContainer + #353D90 + + + + surface + #131318 + + + + surfaceContainer + #1F1F25 + + + + surfaceContainerHigh + #2A2A30 + + + + surfaceContainerHighest + #35353B + + + + + onSurface + #E4E1E9 + + + + onSurfaceVariant + #C6C5D0 + + + + outline + #90909A + + + + outlineVariant + #46464F + + + + error + #FFB4AB + + + + errorContainer + #93000A + + + + + + Типографика — Inter + + Display / 32 / 700 + Headline / 22 / 600 + Title / 18 / 600 + Subtitle / 16 / 500 + Body / 14 / 400 — основной текст в интерфейсе. + Caption / 13 / 400 — подсказки, метаданные. + LABEL / 12 / 500 / UPPER + + + + + Компонент: Settings Item + + + + + + Цифровой ID + + + + + + + + Облачное хранилище + + BETA + + + + + + + + Уведомления + + + + + + + + + Выйти из аккаунта + + + + + Кнопки + + + + Filled · Primary + + + + + Tonal + + + + + Outlined + + + + Text button + + + + + Destructive + + + + + + + + + + Компонент: InfoActionSheet + + + + + + + Облачное хранилище + Через МАХ + + + + + + Работает при белых списках + Можете передать файл при ограничениях. + + + + + Файлы до 4 ГБ, безлимитно + Хранилище большого объёма. + + + + + Без конфиденциальности + Файлы хранятся на сервере МАХ. + + + + + ОК + + + + + Шаблон экрана + + + + + + Заголовок + + + + + + Заголовок состояния + Подпись · описание + + + + + Phone + + + + + + + Облачное хранилище + + + + + + Скоро + Экран в разработке + + + + + Импорт в Figma: File → Place / drag & drop. Слои сохранят имена (id), текст редактируемый. + + diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 96884ff..e822ed5 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -804,14 +804,18 @@ class _ChatScreenState extends State animation: _attachAnim, builder: (context, _) { if (_attachAnim.value == 0) return const SizedBox.shrink(); + final curve = _attachAnim.status == AnimationStatus.reverse + ? Curves.easeIn + : Curves.easeOut; + final t = curve.transform(_attachAnim.value); return Padding( padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), child: ClipRect( child: Align( alignment: Alignment.bottomCenter, - heightFactor: Curves.easeOutCubic.transform(_attachAnim.value), + heightFactor: t, child: Opacity( - opacity: Curves.easeOut.transform(_attachAnim.value), + opacity: t, child: AttachmentPanel( onClose: () => _showAttachmentPanel.value = false, onPickFile: _pickAndUploadFile, diff --git a/lib/frontend/screens/profile/cloud_storage_screen.dart b/lib/frontend/screens/profile/cloud_storage_screen.dart new file mode 100644 index 0000000..d49ccd9 --- /dev/null +++ b/lib/frontend/screens/profile/cloud_storage_screen.dart @@ -0,0 +1,361 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +class CloudStorageScreen extends StatefulWidget { + const CloudStorageScreen({super.key}); + + @override + State createState() => _CloudStorageScreenState(); +} + +class _CloudStorageScreenState extends State + with SingleTickerProviderStateMixin { + static const _translateFactor = 0.7; + static const _horizontalPadding = 32.0; + static const _hintSidePadding = 35.0; + static const _cornerSidePadding = 16.0; + static const _cornerBottomPadding = 24.0; + static const _cornerSlideAmount = 28.0; + + late final _UploadModeController _mode; + + @override + void initState() { + super.initState(); + _mode = _UploadModeController(this); + } + + @override + void dispose() { + _mode.dispose(); + super.dispose(); + } + + void _onBack() { + if (_mode.isOpen) { + _mode.close(); + } else { + Navigator.pop(context); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + elevation: 0, + leading: IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: _onBack, + ), + title: Text( + 'Облачное хранилище', + style: TextStyle(color: cs.onSurface, fontWeight: FontWeight.w600), + ), + ), + body: LayoutBuilder( + builder: (context, constraints) { + final h = constraints.maxHeight; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onVerticalDragUpdate: (d) => _mode.handleDragUpdate(d, h), + onVerticalDragEnd: _mode.handleDragEnd, + child: AnimatedBuilder( + animation: _mode.anim, + builder: (context, _) { + final t = Curves.easeOutCubic.transform(_mode.anim.value); + return Stack( + fit: StackFit.expand, + children: [ + _buildHint(cs, t), + _buildUploadingCenterHint(cs, t), + _buildEmptyState(cs, t, h), + ..._buildCornerActions(cs, t), + ], + ); + }, + ), + ); + }, + ), + ); + } + + Widget _buildHint(ColorScheme cs, double t) { + return Positioned( + top: 0, + left: _hintSidePadding, + right: _hintSidePadding, + child: IgnorePointer( + child: Opacity( + opacity: t, + child: _DragDownHint(color: cs.onSurfaceVariant), + ), + ), + ); + } + + Widget _buildUploadingCenterHint(ColorScheme cs, double t) { + return Center( + child: Opacity( + opacity: t, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: _horizontalPadding), + child: Text( + 'Начните загрузку для прогресс-бара', + textAlign: TextAlign.center, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + ), + ), + ); + } + + Widget _buildEmptyState(ColorScheme cs, double t, double availableHeight) { + return Transform.translate( + offset: Offset(0, -t * availableHeight * _translateFactor), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: _horizontalPadding), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Облачных файлов пока нет...', + textAlign: TextAlign.center, + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + Text( + 'Добавите?', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(height: 24), + FilledButton( + onPressed: _mode.open, + style: FilledButton.styleFrom( + backgroundColor: cs.primary, + foregroundColor: cs.onPrimary, + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 14, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: const Text( + 'Загрузить', + style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600), + ), + ), + ], + ), + ), + ); + } + + List _buildCornerActions(ColorScheme cs, double t) { + final slide = (1 - t) * _cornerSlideAmount; + return [ + Positioned( + bottom: _cornerBottomPadding, + left: _cornerSidePadding - slide, + child: Opacity( + opacity: t, + child: _CornerAction( + icon: Symbols.upload_file, + label: 'С файла', + onTap: () {}, + ), + ), + ), + Positioned( + bottom: _cornerBottomPadding, + right: _cornerSidePadding - slide, + child: Opacity( + opacity: t, + child: _CornerAction( + icon: Symbols.tag, + label: 'По ID', + onTap: () {}, + ), + ), + ), + ]; + } +} + +class _UploadModeController { + static const _openDuration = Duration(milliseconds: 480); + static const _closeDuration = Duration(milliseconds: 320); + static const _dragRangeFactor = 0.55; + static const _flingVelocityThreshold = 280.0; + static const _snapMidpoint = 0.5; + + final AnimationController anim; + + _UploadModeController(TickerProvider vsync) + : anim = AnimationController( + vsync: vsync, + duration: _openDuration, + reverseDuration: _closeDuration, + ); + + bool get isOpen => anim.value > 0; + + void open() => anim.forward(); + void close() => anim.reverse(); + + void handleDragUpdate(DragUpdateDetails d, double availableHeight) { + if (anim.value == 0) return; + final delta = d.primaryDelta ?? 0; + final next = anim.value - delta / (availableHeight * _dragRangeFactor); + anim.value = next.clamp(0.0, 1.0); + } + + void handleDragEnd(DragEndDetails d) { + final v = d.primaryVelocity ?? 0; + if (v.abs() > _flingVelocityThreshold) { + v > 0 ? close() : open(); + return; + } + anim.value < _snapMidpoint ? close() : open(); + } + + void dispose() => anim.dispose(); +} + +class _CornerAction extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback onTap; + + const _CornerAction({ + required this.icon, + required this.label, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: cs.outlineVariant.withValues(alpha: 0.5), + width: 0.5, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: cs.onSurface, size: 20), + const SizedBox(width: 8), + Text( + label, + style: TextStyle( + color: cs.onSurface, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _DragDownHint extends StatefulWidget { + final Color color; + + const _DragDownHint({required this.color}); + + @override + State<_DragDownHint> createState() => _DragDownHintState(); +} + +class _DragDownHintState extends State<_DragDownHint> + with SingleTickerProviderStateMixin { + static const _cycle = Duration(milliseconds: 2800); + static const _activeFraction = 0.4; + static const _height = 3.0; + static const _slotHeight = 28.0; + static const _startY = -6.0; + static const _travel = 16.0; + static const _peakOpacity = 0.32; + + late final AnimationController _c; + + @override + void initState() { + super.initState(); + _c = AnimationController(vsync: this, duration: _cycle)..repeat(); + } + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: _slotHeight, + child: AnimatedBuilder( + animation: _c, + builder: (context, _) { + final ghost = _ghostFor(_c.value); + if (ghost.opacity <= 0) return const SizedBox.shrink(); + return Stack( + children: [ + Positioned( + left: 0, + right: 0, + top: ghost.dy, + height: _height, + child: Opacity( + opacity: ghost.opacity, + child: DecoratedBox( + decoration: BoxDecoration( + color: widget.color, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + ), + ], + ); + }, + ), + ); + } + + ({double dy, double opacity}) _ghostFor(double phase) { + if (phase > _activeFraction) return (dy: 0, opacity: 0); + final local = phase / _activeFraction; + final eased = Curves.easeOutCubic.transform(local); + return ( + dy: _startY + eased * _travel, + opacity: (1 - local) * _peakOpacity, + ); + } +} diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 16300fe..5f9c7d5 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -12,6 +12,7 @@ import '../../../main.dart'; import '../../widgets/info_action_sheet.dart'; import '../auth/login_screen.dart'; import '../auth/proxy_settings_sheet.dart'; +import 'cloud_storage_screen.dart'; import 'customization_screen.dart'; import 'performance_screen.dart'; import 'debug_menu_screen.dart'; @@ -101,7 +102,7 @@ class _SettingsTabState extends State { Future _openCloudStorage(BuildContext context) async { final cs = Theme.of(context).colorScheme; - await showInfoActionSheet( + final ok = await showInfoActionSheet( context, headerIcon: Symbols.cloud, title: 'Облачное хранилище', @@ -128,6 +129,12 @@ class _SettingsTabState extends State { ], confirmLabel: 'ОК', confirmDelay: const Duration(seconds: 3), + seenKey: 'cloud_storage_intro_seen', + ); + if (!ok || !context.mounted) return; + await Navigator.push( + context, + MaterialPageRoute(builder: (_) => const CloudStorageScreen()), ); } diff --git a/pubspec.lock b/pubspec.lock index e5f589c..d2ce143 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -505,10 +505,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.18" material_color_utilities: dependency: transitive description: @@ -902,10 +902,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.9" timezone: dependency: "direct main" description: