This commit is contained in:
@@ -1,21 +1,45 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:trainhub_flutter/injection.dart';
|
||||
import 'package:trainhub_flutter/domain/repositories/chat_repository.dart';
|
||||
import 'package:trainhub_flutter/domain/repositories/note_repository.dart';
|
||||
import 'package:trainhub_flutter/injection.dart';
|
||||
import 'package:trainhub_flutter/presentation/chat/chat_state.dart';
|
||||
|
||||
part 'chat_controller.g.dart';
|
||||
|
||||
const _chatApiUrl = 'http://localhost:8080/v1/chat/completions';
|
||||
|
||||
/// Base system prompt that is always included.
|
||||
const _baseSystemPrompt =
|
||||
'You are a helpful AI fitness assistant for personal trainers. '
|
||||
'Help users design training plans, analyse exercise technique, '
|
||||
'and answer questions about sports science and nutrition.';
|
||||
|
||||
@riverpod
|
||||
class ChatController extends _$ChatController {
|
||||
late ChatRepository _repo;
|
||||
late NoteRepository _noteRepo;
|
||||
|
||||
// Shared Dio client — generous timeout for 7B models running on CPU.
|
||||
final _dio = Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 30),
|
||||
receiveTimeout: const Duration(minutes: 5),
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<ChatState> build() async {
|
||||
_repo = getIt<ChatRepository>();
|
||||
_noteRepo = getIt<NoteRepository>();
|
||||
final sessions = await _repo.getAllSessions();
|
||||
return ChatState(sessions: sessions);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Session management (unchanged)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
Future<void> createSession() async {
|
||||
final session = await _repo.createSession();
|
||||
final sessions = await _repo.getAllSessions();
|
||||
@@ -48,9 +72,15 @@ class ChatController extends _$ChatController {
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Send message (RAG + Step D)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
Future<void> sendMessage(String content) async {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
|
||||
// ── 1. Resolve / create a session ─────────────────────────────────────
|
||||
String sessionId;
|
||||
if (current.activeSession == null) {
|
||||
final session = await _repo.createSession();
|
||||
@@ -62,6 +92,8 @@ class ChatController extends _$ChatController {
|
||||
} else {
|
||||
sessionId = current.activeSession!.id;
|
||||
}
|
||||
|
||||
// ── 2. Persist user message & show typing indicator ───────────────────
|
||||
await _repo.addMessage(
|
||||
sessionId: sessionId,
|
||||
role: 'user',
|
||||
@@ -69,22 +101,73 @@ class ChatController extends _$ChatController {
|
||||
);
|
||||
final messagesAfterUser = await _repo.getMessages(sessionId);
|
||||
state = AsyncValue.data(
|
||||
state.valueOrNull!.copyWith(messages: messagesAfterUser, isTyping: true),
|
||||
state.valueOrNull!.copyWith(
|
||||
messages: messagesAfterUser,
|
||||
isTyping: true,
|
||||
),
|
||||
);
|
||||
await Future<void>.delayed(const Duration(seconds: 1));
|
||||
final String response = _getMockResponse(content);
|
||||
|
||||
// ── 3. RAG: retrieve relevant chunks from the knowledge base ──────────
|
||||
// Gracefully degrades — if Nomic server is unavailable or no chunks
|
||||
// exist, the chat still works with the base system prompt alone.
|
||||
List<String> contextChunks = [];
|
||||
try {
|
||||
contextChunks = await _noteRepo.searchSimilar(content, topK: 3);
|
||||
} catch (_) {
|
||||
// Nomic server not running or no chunks stored — continue without RAG.
|
||||
}
|
||||
|
||||
// ── 4. Build enriched system prompt (Step D) ──────────────────────────
|
||||
final systemPrompt = _buildSystemPrompt(contextChunks);
|
||||
|
||||
// Build the full conversation history so the model maintains context.
|
||||
final history = messagesAfterUser
|
||||
.map(
|
||||
(m) => <String, String>{
|
||||
'role': m.isUser ? 'user' : 'assistant',
|
||||
'content': m.content,
|
||||
},
|
||||
)
|
||||
.toList();
|
||||
|
||||
// ── 5. POST to Qwen (http://localhost:8080/v1/chat/completions) ────────
|
||||
String aiResponse;
|
||||
try {
|
||||
final response = await _dio.post<Map<String, dynamic>>(
|
||||
_chatApiUrl,
|
||||
data: {
|
||||
'messages': [
|
||||
{'role': 'system', 'content': systemPrompt},
|
||||
...history,
|
||||
],
|
||||
'temperature': 0.7,
|
||||
},
|
||||
);
|
||||
aiResponse =
|
||||
response.data!['choices'][0]['message']['content'] as String;
|
||||
} on DioException catch (e) {
|
||||
aiResponse =
|
||||
'Could not reach the AI server (${e.message}). '
|
||||
'Make sure AI models are downloaded and the inference servers have '
|
||||
'had time to start.';
|
||||
} catch (e) {
|
||||
aiResponse = 'An unexpected error occurred: $e';
|
||||
}
|
||||
|
||||
// ── 6. Persist response & update session title on first exchange ───────
|
||||
await _repo.addMessage(
|
||||
sessionId: sessionId,
|
||||
role: 'assistant',
|
||||
content: response,
|
||||
content: aiResponse,
|
||||
);
|
||||
|
||||
final messagesAfterAi = await _repo.getMessages(sessionId);
|
||||
if (messagesAfterAi.length <= 2) {
|
||||
final title = content.length > 30
|
||||
? '${content.substring(0, 30)}...'
|
||||
: content;
|
||||
final title =
|
||||
content.length > 30 ? '${content.substring(0, 30)}…' : content;
|
||||
await _repo.updateSessionTitle(sessionId, title);
|
||||
}
|
||||
|
||||
final sessions = await _repo.getAllSessions();
|
||||
state = AsyncValue.data(
|
||||
state.valueOrNull!.copyWith(
|
||||
@@ -95,15 +178,25 @@ class ChatController extends _$ChatController {
|
||||
);
|
||||
}
|
||||
|
||||
String _getMockResponse(String input) {
|
||||
final String lower = input.toLowerCase();
|
||||
if (lower.contains('plan') || lower.contains('program')) {
|
||||
return "I can help you design a training plan! What are your goals? Strength, hypertrophy, or endurance?";
|
||||
} else if (lower.contains('squat') || lower.contains('bench')) {
|
||||
return "Compound movements are great. Remember to maintain proper form. For squats, keep your chest up and knees tracking over toes.";
|
||||
} else if (lower.contains('nutrition') || lower.contains('eat')) {
|
||||
return "Nutrition is key. Aim for 1.6-2.2g of protein per kg of bodyweight if you're training hard.";
|
||||
}
|
||||
return "I'm your AI training assistant. How can I help you today?";
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// Builds the system prompt, injecting RAG context when available.
|
||||
static String _buildSystemPrompt(List<String> chunks) {
|
||||
if (chunks.isEmpty) return _baseSystemPrompt;
|
||||
|
||||
final contextBlock = chunks
|
||||
.asMap()
|
||||
.entries
|
||||
.map((e) => '[${e.key + 1}] ${e.value}')
|
||||
.join('\n\n');
|
||||
|
||||
return '$_baseSystemPrompt\n\n'
|
||||
'### Relevant notes from the trainer\'s knowledge base:\n'
|
||||
'$contextBlock\n\n'
|
||||
'Use the above context to inform your response when relevant. '
|
||||
'If the context is not directly applicable, rely on your general '
|
||||
'fitness knowledge.';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ part of 'chat_controller.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$chatControllerHash() => r'44a3d0e906eaad16f7a9c292fe847b8bd144c835';
|
||||
String _$chatControllerHash() => r'06ffc6b53c1d878ffc0a758da4f7ee1261ae1340';
|
||||
|
||||
/// See also [ChatController].
|
||||
@ProviderFor(ChatController)
|
||||
|
||||
@@ -3,12 +3,15 @@ import 'dart:math' as math;
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/router/app_router.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/chat_message.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/chat_session.dart';
|
||||
import 'package:trainhub_flutter/presentation/chat/chat_controller.dart';
|
||||
import 'package:trainhub_flutter/presentation/chat/chat_state.dart';
|
||||
import 'package:trainhub_flutter/presentation/settings/ai_model_settings_controller.dart';
|
||||
|
||||
@RoutePage()
|
||||
class ChatPage extends ConsumerStatefulWidget {
|
||||
@@ -72,6 +75,13 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// ── Gate: check whether AI models are present on disk ─────────────────
|
||||
final modelsValidated = ref
|
||||
.watch(aiModelSettingsControllerProvider)
|
||||
.areModelsValidated;
|
||||
|
||||
// Watch chat state regardless of gate so Riverpod keeps the provider
|
||||
// alive and the scroll listener is always registered.
|
||||
final state = ref.watch(chatControllerProvider);
|
||||
final controller = ref.read(chatControllerProvider.notifier);
|
||||
|
||||
@@ -81,22 +91,28 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
next.value!.messages.length) {
|
||||
_scrollToBottom();
|
||||
}
|
||||
if (next.hasValue && next.value!.isTyping && !(prev?.value?.isTyping ?? false)) {
|
||||
if (next.hasValue &&
|
||||
next.value!.isTyping &&
|
||||
!(prev?.value?.isTyping ?? false)) {
|
||||
_scrollToBottom();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Show "models missing" placeholder ─────────────────────────────────
|
||||
if (!modelsValidated) {
|
||||
return const Scaffold(
|
||||
backgroundColor: AppColors.surface,
|
||||
body: _MissingModelsState(),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Normal chat UI ─────────────────────────────────────────────────────
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.surface,
|
||||
body: Row(
|
||||
children: [
|
||||
// --- Side Panel ---
|
||||
_buildSidePanel(state, controller),
|
||||
|
||||
// --- Main Chat Area ---
|
||||
Expanded(
|
||||
child: _buildChatArea(state, controller),
|
||||
),
|
||||
Expanded(child: _buildChatArea(state, controller)),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -119,7 +135,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// New Chat button
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(UIConstants.spacing12),
|
||||
child: SizedBox(
|
||||
@@ -130,7 +145,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
|
||||
const Divider(height: 1, color: AppColors.border),
|
||||
|
||||
// Session list
|
||||
Expanded(
|
||||
child: asyncState.when(
|
||||
data: (data) {
|
||||
@@ -168,7 +182,10 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
error: (_, __) => Center(
|
||||
child: Text(
|
||||
'Error loading sessions',
|
||||
style: TextStyle(color: AppColors.textMuted, fontSize: 13),
|
||||
style: TextStyle(
|
||||
color: AppColors.textMuted,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
loading: () => const Center(
|
||||
@@ -198,9 +215,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hoveredSessionId = session.id),
|
||||
onExit: (_) => setState(() {
|
||||
if (_hoveredSessionId == session.id) {
|
||||
_hoveredSessionId = null;
|
||||
}
|
||||
if (_hoveredSessionId == session.id) _hoveredSessionId = null;
|
||||
}),
|
||||
child: GestureDetector(
|
||||
onTap: () => controller.loadSession(session.id),
|
||||
@@ -220,7 +235,8 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
: isHovered
|
||||
? AppColors.zinc800.withValues(alpha: 0.6)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
border: isActive
|
||||
? Border.all(
|
||||
color: AppColors.accent.withValues(alpha: 0.3),
|
||||
@@ -251,7 +267,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Delete button appears on hover
|
||||
AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
opacity: isHovered ? 1.0 : 0.0,
|
||||
@@ -290,21 +305,17 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
) {
|
||||
return Column(
|
||||
children: [
|
||||
// Messages
|
||||
Expanded(
|
||||
child: asyncState.when(
|
||||
data: (data) {
|
||||
if (data.messages.isEmpty) {
|
||||
return _buildEmptyState();
|
||||
}
|
||||
if (data.messages.isEmpty) return _buildEmptyState();
|
||||
return ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing24,
|
||||
vertical: UIConstants.spacing16,
|
||||
),
|
||||
itemCount:
|
||||
data.messages.length + (data.isTyping ? 1 : 0),
|
||||
itemCount: data.messages.length + (data.isTyping ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == data.messages.length) {
|
||||
return const _TypingIndicator();
|
||||
@@ -349,8 +360,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Input area
|
||||
_buildInputBar(asyncState, controller),
|
||||
],
|
||||
);
|
||||
@@ -386,10 +395,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
const SizedBox(height: UIConstants.spacing8),
|
||||
const Text(
|
||||
'Start a conversation to get personalized advice.',
|
||||
style: TextStyle(
|
||||
color: AppColors.textMuted,
|
||||
fontSize: 13,
|
||||
),
|
||||
style: TextStyle(color: AppColors.textMuted, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -408,9 +414,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border(
|
||||
top: BorderSide(color: AppColors.border, width: 1),
|
||||
),
|
||||
border: Border(top: BorderSide(color: AppColors.border, width: 1)),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing16,
|
||||
@@ -469,7 +473,8 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
onTap: isTyping ? null : () => _sendMessage(controller),
|
||||
child: Icon(
|
||||
Icons.arrow_upward_rounded,
|
||||
color: isTyping ? AppColors.textMuted : AppColors.zinc950,
|
||||
color:
|
||||
isTyping ? AppColors.textMuted : AppColors.zinc950,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
@@ -481,6 +486,124 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Missing Models State — shown when AI files have not been downloaded yet
|
||||
// =============================================================================
|
||||
class _MissingModelsState extends StatelessWidget {
|
||||
const _MissingModelsState();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Icon
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.cloud_download_outlined,
|
||||
size: 36,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: UIConstants.spacing24),
|
||||
|
||||
Text(
|
||||
'AI models are missing.',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
letterSpacing: -0.2,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: UIConstants.spacing8),
|
||||
|
||||
Text(
|
||||
'Please download them to use the AI chat.',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: UIConstants.spacing32),
|
||||
|
||||
// "Go to Settings" button
|
||||
_GoToSettingsButton(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GoToSettingsButton extends StatefulWidget {
|
||||
@override
|
||||
State<_GoToSettingsButton> createState() => _GoToSettingsButtonState();
|
||||
}
|
||||
|
||||
class _GoToSettingsButtonState extends State<_GoToSettingsButton> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: AnimatedContainer(
|
||||
duration: UIConstants.animationDuration,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: _hovered
|
||||
? AppColors.accent.withValues(alpha: 0.85)
|
||||
: AppColors.accent,
|
||||
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
onTap: () => context.router.push(const SettingsRoute()),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing24,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.settings_outlined,
|
||||
size: 16,
|
||||
color: AppColors.zinc950,
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
Text(
|
||||
'Go to Settings',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.zinc950,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// New Chat Button
|
||||
// =============================================================================
|
||||
@@ -507,7 +630,8 @@ class _NewChatButtonState extends State<_NewChatButton> {
|
||||
color: _isHovered
|
||||
? AppColors.zinc700
|
||||
: AppColors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
border: Border.all(color: AppColors.border, width: 1),
|
||||
),
|
||||
child: Material(
|
||||
@@ -590,8 +714,9 @@ class _MessageBubble extends StatelessWidget {
|
||||
],
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||
crossAxisAlignment: isUser
|
||||
? CrossAxisAlignment.end
|
||||
: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
@@ -606,10 +731,12 @@ class _MessageBubble extends StatelessWidget {
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(16),
|
||||
topRight: const Radius.circular(16),
|
||||
bottomLeft:
|
||||
isUser ? const Radius.circular(16) : const Radius.circular(4),
|
||||
bottomRight:
|
||||
isUser ? const Radius.circular(4) : const Radius.circular(16),
|
||||
bottomLeft: isUser
|
||||
? const Radius.circular(16)
|
||||
: const Radius.circular(4),
|
||||
bottomRight: isUser
|
||||
? const Radius.circular(4)
|
||||
: const Radius.circular(16),
|
||||
),
|
||||
border: isUser
|
||||
? null
|
||||
@@ -617,7 +744,7 @@ class _MessageBubble extends StatelessWidget {
|
||||
),
|
||||
child: SelectableText(
|
||||
message.content,
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 14,
|
||||
height: 1.5,
|
||||
@@ -731,17 +858,12 @@ class _TypingIndicatorState extends State<_TypingIndicator>
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: List.generate(3, (index) {
|
||||
// Stagger each dot by 0.2 of the animation cycle
|
||||
final delay = index * 0.2;
|
||||
final t = (_controller.value - delay) % 1.0;
|
||||
// Bounce: use a sin curve over the first half, rest at 0
|
||||
final bounce =
|
||||
t < 0.5 ? math.sin(t * math.pi * 2) * 4.0 : 0.0;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: index == 0 ? 0 : 4,
|
||||
),
|
||||
padding: EdgeInsets.only(left: index == 0 ? 0 : 4),
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, -bounce.abs()),
|
||||
child: Container(
|
||||
|
||||
Reference in New Issue
Block a user