Files
sure/mobile/lib/widgets/typing_indicator.dart
Tristan Katana 6a6548de64 feat(mobile): Add animated TypingIndicator for AI chat responses (#1269)
* feat(mobile): Add animated TypingIndicator widget for AI chat responses

Replaces the static CircularProgressIndicator + "AI is thinking..." text
with an animated TypingIndicator showing pulsing dots while the AI generates
a response. Respects the app color scheme so it works in light and dark themes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix: Normalize stagger progress to [0,1) in TypingIndicator to prevent negative opacity

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(mobile): fix typing indicator visibility and run pub get

The typing indicator was only visible for the duration of the HTTP
POST (~instant) because it was tied to `isSendingMessage`. It now
tracks the full AI response lifecycle via a new `isWaitingForResponse`
state that stays true through polling until the response stabilises.

- Add `isWaitingForResponse` to ChatProvider; set on poll start,
  clear on poll stop with notifyListeners so the UI reacts correctly
- Move TypingIndicator inside the ListView as an assistant bubble
  so it scrolls naturally with the conversation
- Add provider listener that auto-scrolls on every update while
  waiting for a response
- Redesign TypingIndicator: 3-dot sequential bounce animation
  (classic chat style) replacing the simultaneous fade

* feat(mobile): overhaul new-chat flow and fix typing indicator bugs
 chat is created lazily
  on first send, eliminating all pre-conversation flashes and crashes
- Inject user message locally into _currentChat immediately on createChat
  so it renders before the first poll completes
- Hide thinking indicator the moment the first assistant content arrives
  (was waiting one extra 2s poll cycle before disappearing)
- Fix double-spinner on new chat: remove manual showDialog spinner and
  use a local _isCreating flag on the FAB instead

* fix(mboile) : address PR review — widget lifecycle safety and new-chat regression

* Fic(mobile): Add mounted check in post-frame callback

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 22:57:46 +01:00

100 lines
3.0 KiB
Dart

import 'package:flutter/material.dart';
/// Animated 3-dot "Thinking..." indicator shown while the AI generates a response.
/// Each dot bounces up in sequence, giving the classic chat typing indicator feel.
class TypingIndicator extends StatefulWidget {
const TypingIndicator({super.key});
@override
State<TypingIndicator> createState() => _TypingIndicatorState();
}
class _TypingIndicatorState extends State<TypingIndicator>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 1200),
vsync: this,
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final dotColor = colorScheme.onSurfaceVariant;
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'Thinking',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
const SizedBox(width: 6),
SizedBox(
height: 20,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: List.generate(3, (index) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
final offset = _dotOffset(index, _controller.value);
return Padding(
padding: EdgeInsets.only(right: index < 2 ? 5 : 0),
child: Transform.translate(
offset: Offset(0, offset),
child: Container(
width: 7,
height: 7,
decoration: BoxDecoration(
color: dotColor.withValues(alpha: 0.75),
shape: BoxShape.circle,
),
),
),
);
},
);
}),
),
),
],
);
}
/// Returns the vertical offset (px) for a dot at [index] given the
/// controller's current [value] in [0, 1).
/// Each dot is delayed by 1/3 of the cycle so they bounce in sequence.
double _dotOffset(int index, double value) {
const bounceHeight = 5.0;
const dotCount = 3;
final phase = (value - index / dotCount + 1.0) % 1.0;
// Bounce occupies the first 40% of each dot's phase; rest is idle.
if (phase < 0.2) {
// Rising: 0 → peak
return -bounceHeight * (phase / 0.2);
} else if (phase < 0.4) {
// Falling: peak → 0
return -bounceHeight * (1.0 - (phase - 0.2) / 0.2);
}
return 0.0;
}
}