From b1de8f436fc67bb10d309be4da986c5fb235fc0b Mon Sep 17 00:00:00 2001 From: SuTechs Date: Sat, 13 Jun 2026 16:52:22 +0530 Subject: [PATCH] wip: 100x chat screen redesign (category identity, aurora, insights) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full chat-screen visual overhaul + a model-free "your money" insight engine. Category icons + tints, tabular-figure amounts, accent bars, compact rows; daily summary dividers; live "Your money" app bar with sparkline; aurora background with low-end/reduce-motion fallback; interleaved once-a-day insight bubbles (local only, never synced). DRAFT — visual direction needs rework, especially the dark themes (Carbon/Midnight): bubbles read too heavy/opaque (low-end solidify + loud accent treatment on true-black). Picking this up in a separate session. Analyzer clean, 55 tests pass, Android release + iOS build. Co-Authored-By: Claude Fable 5 --- lib/data/api/hive/service_extension.dart | 6 + lib/data/bloc/app_bloc.dart | 5 + lib/data/bloc/insight_bloc.dart | 12 + lib/data/command/commands.dart | 15 + lib/data/command/insight/insight_command.dart | 64 +++ lib/data/data/insight/insight.dart | 65 +++ lib/data/utils/ai/insight_generator.dart | 102 ++++ lib/data/utils/category_style.dart | 126 +++++ lib/main.dart | 9 +- lib/screens/chat/chat_screen.dart | 10 + .../chat/components/chat_background.dart | 134 +++-- lib/screens/chat/components/chat_bubble.dart | 496 +++++++++--------- lib/screens/chat/components/date_header.dart | 102 +++- lib/screens/chat/components/glass.dart | 65 +++ .../chat/components/glass_app_bar.dart | 308 ++++++----- .../chat/components/insight_bubble.dart | 155 ++++++ .../chat/components/transaction_list.dart | 154 +++--- lib/screens/chat/theme/chat_theme.dart | 11 + test/insight_test.dart | 100 ++++ 19 files changed, 1426 insertions(+), 513 deletions(-) create mode 100644 lib/data/bloc/insight_bloc.dart create mode 100644 lib/data/command/insight/insight_command.dart create mode 100644 lib/data/data/insight/insight.dart create mode 100644 lib/data/utils/ai/insight_generator.dart create mode 100644 lib/data/utils/category_style.dart create mode 100644 lib/screens/chat/components/glass.dart create mode 100644 lib/screens/chat/components/insight_bubble.dart create mode 100644 test/insight_test.dart diff --git a/lib/data/api/hive/service_extension.dart b/lib/data/api/hive/service_extension.dart index 730d465..234ab0d 100644 --- a/lib/data/api/hive/service_extension.dart +++ b/lib/data/api/hive/service_extension.dart @@ -33,6 +33,12 @@ extension AppHiveService on HiveService { ? stringBox.delete('ai.installedModelId') : stringBox.put('ai.installedModelId', id); + /// Insights: the "your money" feed, stored as a JSON array string. Local + /// only — deliberately NOT part of the Drive backup. + String? get getInsightsFeed => stringBox.get('insights.feed'); + Future setInsightsFeed(String json) => + stringBox.put('insights.feed', json); + /// Settings String? get getCurrency => stringBox.get('currency'); Future setCurrency(String currency) => diff --git a/lib/data/bloc/app_bloc.dart b/lib/data/bloc/app_bloc.dart index 39f0973..615a3e9 100644 --- a/lib/data/bloc/app_bloc.dart +++ b/lib/data/bloc/app_bloc.dart @@ -35,6 +35,11 @@ class AppBloc extends AbstractBloc { set hasBootstrapped(bool value) => notify(() => _hasBootstrapped = value); + /// True on RAM-constrained devices (computed once at bootstrap). Drives + /// visual fallbacks: blur degrades to a flat translucent fill and the + /// aurora background stops animating, keeping budget phones smooth. + bool isLowEndDevice = false; + /// Auth // Current User late UserData _currentUser = diff --git a/lib/data/bloc/insight_bloc.dart b/lib/data/bloc/insight_bloc.dart new file mode 100644 index 0000000..0fa9e4d --- /dev/null +++ b/lib/data/bloc/insight_bloc.dart @@ -0,0 +1,12 @@ +import '../data/insight/insight.dart'; +import 'abstract.dart'; + +/// Holds the "your money" insight feed shown interleaved in the chat thread. +/// In-memory mirror of the persisted JSON feed (see InsightCommand). +class InsightBloc extends AbstractBloc { + List _feed = const []; + + List get feed => _feed; + + void setFeed(List feed) => notify(() => _feed = feed); +} diff --git a/lib/data/command/commands.dart b/lib/data/command/commands.dart index 5a85487..ae9e646 100644 --- a/lib/data/command/commands.dart +++ b/lib/data/command/commands.dart @@ -1,11 +1,14 @@ import 'dart:developer'; +import '../api/ai/device_capability.dart'; import '../api/hive/hive_service.dart'; import '../bloc/ai_bloc.dart'; import '../bloc/app_bloc.dart'; import '../bloc/expense_bloc.dart'; +import '../bloc/insight_bloc.dart'; import '../bloc/sync_bloc.dart'; import 'expense/expense_command.dart'; +import 'insight/insight_command.dart'; import 'sync/sync_command.dart'; abstract class BaseAppCommand { @@ -21,6 +24,8 @@ abstract class BaseAppCommand { static final blocAi = AiBloc(); + static final blocInsight = InsightBloc(); + /// add other blocs here HiveService get hive => _hive; @@ -29,6 +34,8 @@ abstract class BaseAppCommand { ExpenseBloc get expenseBloc => blocExpense; + InsightBloc get insightBloc => blocInsight; + /// init static Future init() async { @@ -52,6 +59,14 @@ abstract class BaseAppCommand { // Fetch and sync expenses ExpenseCommand().refresh(loadDummy: false); + // One-time device-capability check drives visual fallbacks (blur, aurora). + final ramMb = await DeviceCapability.physicalRamMb(); + blocApp.isLowEndDevice = ramMb != null && ramMb < 4000; + + // Load the persisted insight feed and generate today's if due. + InsightCommand().hydrate(); + InsightCommand().maybeGenerate(); + // Restore sync state and pull remote changes (never blocks bootstrap) SyncCommand().hydrate(); SyncCommand().syncOnResume(); diff --git a/lib/data/command/insight/insight_command.dart b/lib/data/command/insight/insight_command.dart new file mode 100644 index 0000000..adf4dad --- /dev/null +++ b/lib/data/command/insight/insight_command.dart @@ -0,0 +1,64 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; + +import '../../api/hive/service_extension.dart'; +import '../../data/insight/insight.dart'; +import '../../utils/ai/insight_generator.dart'; +import '../commands.dart'; + +/// Owns the persisted "your money" insight feed. Insights are generated +/// model-free from the user's data, at most one per day, pruned to a recent +/// window, and stored locally (never synced). +class InsightCommand extends BaseAppCommand { + static const _maxFeed = 30; + + /// Loads the persisted feed into the bloc at bootstrap. + void hydrate() { + insightBloc.setFeed(_load()); + } + + /// Generates today's insight if one is due (nothing already created today + /// and the generator finds something worth saying), then persists. + Future maybeGenerate({DateTime? now}) async { + final at = now ?? DateTime.now(); + final feed = _load(); + + if (feed.any((i) => _sameDay(i.date, at))) return; + + final insight = InsightGenerator.generate( + expenses: expenseBloc.expenses, + now: at, + currency: appBloc.currency, + ); + if (insight == null) return; + + final updated = [...feed, insight]; + updated.sort((a, b) => a.createdAt.compareTo(b.createdAt)); + final pruned = updated.length > _maxFeed + ? updated.sublist(updated.length - _maxFeed) + : updated; + + insightBloc.setFeed(pruned); + await hive.setInsightsFeed( + jsonEncode(pruned.map((i) => i.toJson()).toList()), + ); + } + + List _load() { + final raw = hive.getInsightsFeed; + if (raw == null || raw.isEmpty) return const []; + try { + final list = jsonDecode(raw) as List; + return list + .map((e) => InsightData.fromJson(e as Map)) + .toList(); + } catch (e) { + debugPrint('InsightCommand._load: $e'); + return const []; + } + } + + bool _sameDay(DateTime a, DateTime b) => + a.year == b.year && a.month == b.month && a.day == b.day; +} diff --git a/lib/data/data/insight/insight.dart b/lib/data/data/insight/insight.dart new file mode 100644 index 0000000..f1017f1 --- /dev/null +++ b/lib/data/data/insight/insight.dart @@ -0,0 +1,65 @@ +/// A model-free "your money" message shown in the chat thread. Generated +/// deterministically from StatisticsHelper (no AI model needed), persisted +/// in its own lightweight JSON feed, and NEVER added to ExpenseData or the +/// Drive backup. +enum InsightKind { dailyRecap, categoryMilestone, topCategoryWeek, savings } + +/// One labelled bar in an insight's mini chart (fraction is 0..1 of the max). +class InsightBar { + final String label; + final double fraction; + + const InsightBar(this.label, this.fraction); + + Map toJson() => {'l': label, 'f': fraction}; + + factory InsightBar.fromJson(Map json) => InsightBar( + json['l'] as String? ?? '', + (json['f'] as num?)?.toDouble() ?? 0, + ); +} + +class InsightData { + final String id; + final int createdAt; // epoch millis + final InsightKind kind; + final String text; + final List bars; + final String? category; + + const InsightData({ + required this.id, + required this.createdAt, + required this.kind, + required this.text, + this.bars = const [], + this.category, + }); + + DateTime get date => DateTime.fromMillisecondsSinceEpoch(createdAt); + + Map toJson() => { + 'id': id, + 'createdAt': createdAt, + 'kind': kind.name, + 'text': text, + if (bars.isNotEmpty) 'bars': bars.map((b) => b.toJson()).toList(), + if (category != null) 'category': category, + }; + + factory InsightData.fromJson(Map json) => InsightData( + id: json['id'] as String, + createdAt: json['createdAt'] as int, + kind: InsightKind.values.firstWhere( + (k) => k.name == json['kind'], + orElse: () => InsightKind.dailyRecap, + ), + text: json['text'] as String? ?? '', + bars: + (json['bars'] as List?) + ?.map((e) => InsightBar.fromJson(e as Map)) + .toList() ?? + const [], + category: json['category'] as String?, + ); +} diff --git a/lib/data/utils/ai/insight_generator.dart b/lib/data/utils/ai/insight_generator.dart new file mode 100644 index 0000000..4d0f4ba --- /dev/null +++ b/lib/data/utils/ai/insight_generator.dart @@ -0,0 +1,102 @@ +import 'package:intl/intl.dart'; + +import '../../data/expense/expense.dart'; +import '../../data/insight/insight.dart'; +import '../statistics_helper.dart'; + +/// Produces the single most relevant "your money" insight for *now*, computed +/// entirely from [StatisticsHelper] — no AI model involved, so it works for +/// every user. Returns null when there's nothing worth saying. Pure and +/// deterministic (id is keyed to kind + day) so it's unit-testable and +/// dedupes to one insight per day. +class InsightGenerator { + InsightGenerator._(); + + static InsightData? generate({ + required List expenses, + required DateTime now, + required String currency, + }) { + if (expenses.isEmpty) return null; + final fmt = NumberFormat('#,##0.##'); + String money(double v) => '$currency${fmt.format(v)}'; + final dayKey = + '${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}'; + InsightData make(InsightKind kind, String text, + {List bars = const [], String? category}) => + InsightData( + id: '${kind.name}_$dayKey', + createdAt: now.millisecondsSinceEpoch, + kind: kind, + text: text, + bars: bars, + category: category, + ); + + // 1. Yesterday recap — only once there's been activity. + final yesterday = now.subtract(const Duration(days: 1)); + final yStats = StatisticsHelper(expenses, period: 'D', referenceDate: yesterday); + if (yStats.totalSpending > 0) { + final top = yStats.topCategory; + final tail = top != null ? ", mostly #${top.key}" : ""; + return make( + InsightKind.dailyRecap, + "Yesterday you spent ${money(yStats.totalSpending)}$tail.", + category: top?.key, + ); + } + + // 2. Category milestone — this month already passed last month's total. + final monthStats = StatisticsHelper(expenses, period: 'M', referenceDate: now); + final lastMonth = DateTime(now.year, now.month - 1, 15); + final lastMonthStats = + StatisticsHelper(expenses, period: 'M', referenceDate: lastMonth); + final lastByCat = { + for (final c in lastMonthStats.categoryStats) c.category: c.totalAmount, + }; + for (final c in monthStats.categoryStats) { + final prev = lastByCat[c.category] ?? 0; + if (prev > 0 && c.totalAmount > prev) { + return make( + InsightKind.categoryMilestone, + "You've already spent more on #${c.category} this month " + "(${money(c.totalAmount)}) than all of last month (${money(prev)}).", + category: c.category, + ); + } + } + + // 3. Top category this week, with a mini chart. + final weekStats = StatisticsHelper(expenses, period: 'W', referenceDate: now); + final weekCats = weekStats.categoryStats; + if (weekStats.totalSpending > 0 && weekCats.isNotEmpty) { + final top = weekCats.first; + final max = top.totalAmount; + final bars = [ + for (final c in weekCats.take(3)) + InsightBar(c.category, max == 0 ? 0 : c.totalAmount / max), + ]; + final pct = weekStats.totalSpending == 0 + ? 0 + : (top.totalAmount / weekStats.totalSpending * 100).round(); + return make( + InsightKind.topCategoryWeek, + "This week #${top.category} is your top category at " + "${money(top.totalAmount)} ($pct% of spending).", + bars: bars, + category: top.category, + ); + } + + // 4. Positive savings note for the month. + if (monthStats.totalIncome > 0 && monthStats.savingsRate >= 20) { + return make( + InsightKind.savings, + "You've saved ${monthStats.savingsRate.round()}% of your income " + "this month — ${money(monthStats.totalSaved)} kept. Nice.", + ); + } + + return null; + } +} diff --git a/lib/data/utils/category_style.dart b/lib/data/utils/category_style.dart new file mode 100644 index 0000000..6b7b120 --- /dev/null +++ b/lib/data/utils/category_style.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; + +/// Maps a category name to a rounded Material icon so each transaction bubble +/// reads at a glance, instead of every bubble of a type showing the same +/// arrow. Color stays type-/theme-driven (themes own the palette) — this is +/// purely the icon dimension. +/// +/// Covers the 82 default categories in ExpenseBloc; unknown/custom categories +/// fall back to a deterministic icon picked from the name so the same custom +/// category always looks the same. +class CategoryStyle { + CategoryStyle._(); + + static const _icons = { + // Expense + 'food': Icons.restaurant_rounded, + 'groceries': Icons.local_grocery_store_rounded, + 'travel': Icons.flight_rounded, + 'transport': Icons.directions_car_rounded, + 'shopping': Icons.shopping_bag_rounded, + 'bills': Icons.receipt_long_rounded, + 'rent': Icons.home_rounded, + 'fuel': Icons.local_gas_station_rounded, + 'coffee': Icons.local_cafe_rounded, + 'dining': Icons.restaurant_menu_rounded, + 'snacks': Icons.bakery_dining_rounded, + 'entertainment': Icons.celebration_rounded, + 'health': Icons.favorite_rounded, + 'utilities': Icons.bolt_rounded, + 'subscriptions': Icons.autorenew_rounded, + 'clothing': Icons.checkroom_rounded, + 'movies': Icons.movie_rounded, + 'medicine': Icons.medication_rounded, + 'phone': Icons.smartphone_rounded, + 'internet': Icons.wifi_rounded, + 'fitness': Icons.fitness_center_rounded, + 'gym': Icons.sports_gymnastics_rounded, + 'education': Icons.school_rounded, + 'personal': Icons.person_rounded, + 'beauty': Icons.spa_rounded, + 'electronics': Icons.devices_rounded, + 'games': Icons.sports_esports_rounded, + 'streaming': Icons.live_tv_rounded, + 'gifts': Icons.card_giftcard_rounded, + 'household': Icons.cleaning_services_rounded, + 'parking': Icons.local_parking_rounded, + 'laundry': Icons.local_laundry_service_rounded, + 'books': Icons.menu_book_rounded, + 'courses': Icons.cast_for_education_rounded, + 'vacation': Icons.beach_access_rounded, + 'insurance': Icons.shield_rounded, + 'repairs': Icons.build_rounded, + 'maintenance': Icons.handyman_rounded, + 'donations': Icons.volunteer_activism_rounded, + 'pets': Icons.pets_rounded, + 'kids': Icons.child_care_rounded, + 'family': Icons.family_restroom_rounded, + 'taxes': Icons.account_balance_rounded, + 'fees': Icons.request_quote_rounded, + 'alcohol': Icons.local_bar_rounded, + // Income + 'salary': Icons.work_rounded, + 'bonus': Icons.emoji_events_rounded, + 'freelance': Icons.laptop_mac_rounded, + 'consulting': Icons.support_agent_rounded, + 'commission': Icons.percent_rounded, + 'tips': Icons.savings_rounded, + 'refund': Icons.replay_rounded, + 'cashback': Icons.currency_exchange_rounded, + 'gift': Icons.redeem_rounded, + 'dividend': Icons.pie_chart_rounded, + 'interest': Icons.trending_up_rounded, + 'rental': Icons.apartment_rounded, + 'royalty': Icons.workspace_premium_rounded, + 'sales': Icons.sell_rounded, + 'reimbursement': Icons.receipt_rounded, + 'allowance': Icons.wallet_rounded, + 'pension': Icons.elderly_rounded, + 'lottery': Icons.confirmation_num_rounded, + // Investment + 'stocks': Icons.trending_up_rounded, + 'crypto': Icons.currency_bitcoin_rounded, + 'mutual': Icons.donut_large_rounded, + 'bonds': Icons.description_rounded, + 'gold': Icons.diamond_rounded, + 'silver': Icons.brightness_7_rounded, + 'property': Icons.location_city_rounded, + 'realestate': Icons.maps_home_work_rounded, + 'ppf': Icons.account_balance_rounded, + 'nps': Icons.elderly_rounded, + 'fd': Icons.lock_clock_rounded, + 'rd': Icons.event_repeat_rounded, + 'sip': Icons.calendar_month_rounded, + 'etf': Icons.bar_chart_rounded, + 'futures': Icons.schedule_rounded, + 'options': Icons.alt_route_rounded, + 'commodities': Icons.grain_rounded, + 'retirement': Icons.beach_access_rounded, + 'savings': Icons.savings_rounded, + }; + + /// Generic icons for unknown/custom categories. Index chosen by a stable + /// hash of the name so a given custom category is always the same icon. + static const _fallbacks = [ + Icons.label_rounded, + Icons.category_rounded, + Icons.sell_rounded, + Icons.bookmark_rounded, + Icons.local_offer_rounded, + Icons.toll_rounded, + Icons.interests_rounded, + Icons.style_rounded, + ]; + + static IconData iconFor(String category) { + final key = category.trim().toLowerCase(); + final known = _icons[key]; + if (known != null) return known; + + var hash = 0; + for (final unit in key.codeUnits) { + hash = (hash + unit) % _fallbacks.length; + } + return _fallbacks[hash]; + } +} diff --git a/lib/main.dart b/lib/main.dart index f60e780..cd58121 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import 'data/bloc/app_bloc.dart'; import 'data/command/ai/ai_model_command.dart'; import 'data/command/commands.dart'; +import 'data/command/insight/insight_command.dart'; import 'data/command/sync/sync_command.dart'; import 'screens/main_screen.dart'; import 'screens/onboarding/onboarding_screen.dart'; @@ -31,6 +32,9 @@ void main() async { // AI Bloc - on-device assistant state ChangeNotifierProvider.value(value: BaseAppCommand.blocAi), + + // Insight Bloc - "your money" thread messages + ChangeNotifierProvider.value(value: BaseAppCommand.blocInsight), ], child: const MyApp(), ), @@ -60,7 +64,10 @@ class _MyAppState extends State { SyncCommand().flushPending(); AiModelCommand().unload(); }, - onResume: () => SyncCommand().syncOnResume(), + onResume: () { + SyncCommand().syncOnResume(); + InsightCommand().maybeGenerate(); + }, ); } diff --git a/lib/screens/chat/chat_screen.dart b/lib/screens/chat/chat_screen.dart index f828034..e4a6925 100644 --- a/lib/screens/chat/chat_screen.dart +++ b/lib/screens/chat/chat_screen.dart @@ -6,6 +6,7 @@ import 'package:uuid/uuid.dart'; import '../../data/bloc/app_bloc.dart'; import '../../data/bloc/expense_bloc.dart'; import '../../data/command/expense/expense_command.dart'; +import '../../data/command/insight/insight_command.dart'; import '../../data/data/expense/expense.dart'; import 'components/glass_app_bar.dart'; import 'components/chat_background.dart'; @@ -62,6 +63,15 @@ class _ChatScreenState extends State { final Uuid _uuid = const Uuid(); final ChatInteractionProvider _interaction = ChatInteractionProvider(); + @override + void initState() { + super.initState(); + // Surface today's "your money" insight when the thread opens. + WidgetsBinding.instance.addPostFrameCallback( + (_) => InsightCommand().maybeGenerate(), + ); + } + @override void dispose() { _scrollController.dispose(); diff --git a/lib/screens/chat/components/chat_background.dart b/lib/screens/chat/components/chat_background.dart index 76254ce..295856f 100644 --- a/lib/screens/chat/components/chat_background.dart +++ b/lib/screens/chat/components/chat_background.dart @@ -1,9 +1,14 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../../data/bloc/app_bloc.dart'; import '../theme/chat_theme.dart'; -/// Animated gradient background for the chat screen. -/// Uses ChatTheme colors for consistent theming. +/// Aurora background: soft radial colour blobs drifting slowly over the +/// theme's gradient. Falls back to a static gradient on low-end devices or +/// when the OS requests reduced motion, keeping budget phones smooth. class ChatBackground extends StatefulWidget { final ChatTheme theme; final Widget? child; @@ -16,21 +21,16 @@ class ChatBackground extends StatefulWidget { class _ChatBackgroundState extends State with SingleTickerProviderStateMixin { - late AnimationController _controller; - late Animation _animation; + late final AnimationController _controller; @override void initState() { super.initState(); + // Slow drift — barely perceptible so it never competes with content. _controller = AnimationController( - duration: const Duration(seconds: 8), + duration: const Duration(seconds: 24), vsync: this, - )..repeat(reverse: true); - - _animation = Tween( - begin: 0, - end: 1, - ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut)); + ); } @override @@ -41,60 +41,104 @@ class _ChatBackgroundState extends State @override Widget build(BuildContext context) { - final colors = widget.theme.backgroundGradient; + final lowEnd = context.select((AppBloc b) => b.isLowEndDevice); + final reduceMotion = MediaQuery.of(context).disableAnimations; + final animate = !lowEnd && !reduceMotion; + + if (animate) { + if (!_controller.isAnimating) _controller.repeat(reverse: true); + } else if (_controller.isAnimating) { + _controller.stop(); + } + + final theme = widget.theme; + final base = theme.backgroundGradient; + final blobs = [ + theme.outgoingAccent, + theme.investedAccent, + theme.incomingAccent, + ]; + + if (!animate) { + return DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: base, + ), + ), + child: CustomPaint( + painter: _AuroraPainter(t: 0.4, blobs: blobs, animate: false), + child: widget.child, + ), + ); + } return AnimatedBuilder( - animation: _animation, + animation: _controller, builder: (context, child) { - return Container( + return DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, - end: Alignment( - 0.5 + (_animation.value * 0.5), - 1.0 - (_animation.value * 0.2), - ), - colors: [ - Color.lerp(colors[0], colors[1], _animation.value)!, - Color.lerp(colors[1], colors[2], _animation.value)!, - Color.lerp(colors[2], colors[0], _animation.value)!, - ], + end: Alignment.bottomRight, + colors: base, + ), + ), + child: CustomPaint( + painter: _AuroraPainter( + t: _controller.value, + blobs: blobs, + animate: true, ), + child: child, ), - child: child, ); }, - child: CustomPaint( - painter: _SubtlePatternPainter(color: widget.theme.patternColor), - child: widget.child, - ), + child: widget.child, ); } } -/// Paints a subtle dot pattern for texture -class _SubtlePatternPainter extends CustomPainter { - final Color color; +/// Paints 3 soft radial blobs whose centres drift on slow sine paths. +class _AuroraPainter extends CustomPainter { + final double t; + final List blobs; + final bool animate; - _SubtlePatternPainter({required this.color}); + _AuroraPainter({required this.t, required this.blobs, required this.animate}); @override void paint(Canvas canvas, Size size) { - final paint = Paint() - ..color = color - ..style = PaintingStyle.fill; - - const spacing = 24.0; - const dotRadius = 1.0; - - for (double x = 0; x < size.width; x += spacing) { - for (double y = 0; y < size.height; y += spacing) { - canvas.drawCircle(Offset(x, y), dotRadius, paint); - } + final w = size.width; + final h = size.height; + // Phase-shifted anchor points so the three blobs never clump. + final anchors = [ + Offset(0.20 * w, 0.18 * h), + Offset(0.85 * w, 0.30 * h), + Offset(0.65 * w, 0.88 * h), + ]; + + for (var i = 0; i < blobs.length; i++) { + final phase = t * 2 * math.pi + i * 2.1; + final drift = animate ? 0.06 : 0.0; + final c = anchors[i].translate( + math.cos(phase) * drift * w, + math.sin(phase) * drift * h, + ); + final radius = w * 0.55; + final paint = Paint() + ..shader = RadialGradient( + colors: [ + blobs[i].withValues(alpha: 0.22), + blobs[i].withValues(alpha: 0.0), + ], + ).createShader(Rect.fromCircle(center: c, radius: radius)); + canvas.drawCircle(c, radius, paint); } } @override - bool shouldRepaint(covariant _SubtlePatternPainter oldDelegate) => - color != oldDelegate.color; + bool shouldRepaint(_AuroraPainter old) => old.t != t || old.blobs != blobs; } diff --git a/lib/screens/chat/components/chat_bubble.dart b/lib/screens/chat/components/chat_bubble.dart index 36d02d2..cd58ca6 100644 --- a/lib/screens/chat/components/chat_bubble.dart +++ b/lib/screens/chat/components/chat_bubble.dart @@ -1,13 +1,14 @@ -import 'dart:ui'; - import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import '../../../data/data/expense/expense.dart'; +import '../../../data/utils/category_style.dart'; import '../theme/chat_theme.dart'; +import 'glass.dart'; -/// Chat bubble widget with custom shape and glass effect. -/// Uses ChatTheme colors for consistent theming. +/// Chat bubble for a transaction. Glass surface (degrades on low-end), a +/// per-category icon chip, tabular-figure amount, and a thin accent bar on +/// the tail edge. Noteless transactions render as a compact single row. class ChatBubble extends StatelessWidget { final String note; final double amount; @@ -30,91 +31,76 @@ class ChatBubble extends StatelessWidget { @override Widget build(BuildContext context) { - final bool isLeftAligned = type == TransactionType.incoming; - - // Get colors based on type from theme - Color bubbleBg; - Color accentColor; - Color borderColor; - IconData categoryIcon; + final isLeftAligned = type == TransactionType.incoming; + final accent = _accentColor(); + final bg = _bubbleBg(); + final border = _borderColor(); + final icon = CategoryStyle.iconFor(category); + final compact = note.isEmpty; - switch (type) { - case TransactionType.incoming: - bubbleBg = theme.incomingBg; - accentColor = theme.incomingAccent; - borderColor = theme.incomingBorder; - categoryIcon = Icons.arrow_downward_rounded; - break; - case TransactionType.outgoing: - bubbleBg = theme.outgoingBg; - accentColor = theme.outgoingAccent; - borderColor = theme.outgoingBorder; - categoryIcon = Icons.arrow_upward_rounded; - break; - case TransactionType.invested: - bubbleBg = theme.investedBg; - accentColor = theme.investedAccent; - borderColor = theme.investedBorder; - categoryIcon = Icons.auto_graph_rounded; - break; - } + // Accent bar sits on the tail side: left for incoming, right for the rest. + final radius = BorderRadius.only( + topLeft: Radius.circular(isLeftAligned ? 5 : 20), + topRight: Radius.circular(isLeftAligned ? 20 : 5), + bottomLeft: const Radius.circular(20), + bottomRight: const Radius.circular(20), + ); return Align( alignment: isLeftAligned ? Alignment.centerLeft : Alignment.centerRight, child: Padding( - padding: const EdgeInsets.symmetric(vertical: 6.0, horizontal: 16.0), - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(22), - boxShadow: [ - BoxShadow( - color: accentColor.withValues(alpha: 0.15), - blurRadius: 12, - offset: const Offset(0, 4), - ), - ], - ), - child: ClipPath( - clipper: _BubbleClipper(isLeftAligned: isLeftAligned), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8), - child: CustomPaint( - painter: _ModernBubblePainter( - color: bubbleBg, - borderColor: borderColor, - isLeftAligned: isLeftAligned, - ), - child: Container( - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.75, - minWidth: 120, - ), - padding: const EdgeInsets.fromLTRB(16, 14, 16, 12), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (note.isNotEmpty) ...[ - _BubbleNote(note: note, theme: theme), - const SizedBox(height: 6), - ], - _BubbleAmount( - amount: amount, - type: type, - color: accentColor, - currency: currency, - ), - const SizedBox(height: 10), - _BubbleFooter( - category: category, - date: date, - accentColor: accentColor, - icon: categoryIcon, - theme: theme, - ), - ], + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 16), + child: Glass( + color: bg, + borderRadius: radius, + border: Border.all(color: border, width: 1.2), + boxShadow: [ + BoxShadow( + color: accent.withValues(alpha: 0.15), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.76, + minWidth: compact ? 0 : 130, + ), + child: IntrinsicHeight( + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Tail-side accent bar for left-aligned (incoming) bubbles. + if (isLeftAligned) _AccentBar(color: accent), + Flexible( + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 11), + child: compact + ? _CompactRow( + icon: icon, + amount: amount, + type: type, + accent: accent, + category: category, + currency: currency, + theme: theme, + ) + : _FullBody( + icon: icon, + note: note, + amount: amount, + type: type, + accent: accent, + category: category, + date: date, + currency: currency, + theme: theme, + ), + ), ), - ), + if (!isLeftAligned) _AccentBar(color: accent), + ], ), ), ), @@ -122,236 +108,232 @@ class ChatBubble extends StatelessWidget { ), ); } -} -/// Custom clipper for bubble shape with sharp corner -class _BubbleClipper extends CustomClipper { - final bool isLeftAligned; + Color _accentColor() => switch (type) { + TransactionType.incoming => theme.incomingAccent, + TransactionType.outgoing => theme.outgoingAccent, + TransactionType.invested => theme.investedAccent, + }; - _BubbleClipper({required this.isLeftAligned}); + Color _bubbleBg() => switch (type) { + TransactionType.incoming => theme.incomingBg, + TransactionType.outgoing => theme.outgoingBg, + TransactionType.invested => theme.investedBg, + }; - @override - Path getClip(Size size) { - final path = Path(); - const double radius = 20.0; - const double sharpRadius = 4.0; - - if (isLeftAligned) { - // Left aligned: sharp top-left corner - path.moveTo(sharpRadius, 0); - path.lineTo(size.width - radius, 0); - path.quadraticBezierTo(size.width, 0, size.width, radius); - path.lineTo(size.width, size.height - radius); - path.quadraticBezierTo( - size.width, - size.height, - size.width - radius, - size.height, - ); - path.lineTo(radius, size.height); - path.quadraticBezierTo(0, size.height, 0, size.height - radius); - path.lineTo(0, sharpRadius); - path.quadraticBezierTo(0, 0, sharpRadius, 0); - } else { - // Right aligned: sharp top-right corner - path.moveTo(radius, 0); - path.lineTo(size.width - sharpRadius, 0); - path.quadraticBezierTo(size.width, 0, size.width, sharpRadius); - path.lineTo(size.width, size.height - radius); - path.quadraticBezierTo( - size.width, - size.height, - size.width - radius, - size.height, - ); - path.lineTo(radius, size.height); - path.quadraticBezierTo(0, size.height, 0, size.height - radius); - path.lineTo(0, radius); - path.quadraticBezierTo(0, 0, radius, 0); - } - path.close(); - return path; - } - - @override - bool shouldReclip(covariant CustomClipper oldClipper) => false; + Color _borderColor() => switch (type) { + TransactionType.incoming => theme.incomingBorder, + TransactionType.outgoing => theme.outgoingBorder, + TransactionType.invested => theme.investedBorder, + }; } -/// Custom painter for bubble with border -class _ModernBubblePainter extends CustomPainter { +class _AccentBar extends StatelessWidget { final Color color; - final Color borderColor; - final bool isLeftAligned; - - _ModernBubblePainter({ - required this.color, - required this.borderColor, - required this.isLeftAligned, - }); + const _AccentBar({required this.color}); @override - void paint(Canvas canvas, Size size) { - final paint = Paint() - ..color = color - ..style = PaintingStyle.fill; - - final path = Path(); - const double radius = 20.0; - const double sharpRadius = 4.0; - - if (isLeftAligned) { - path.moveTo(sharpRadius, 0); - path.lineTo(size.width - radius, 0); - path.quadraticBezierTo(size.width, 0, size.width, radius); - path.lineTo(size.width, size.height - radius); - path.quadraticBezierTo( - size.width, - size.height, - size.width - radius, - size.height, - ); - path.lineTo(radius, size.height); - path.quadraticBezierTo(0, size.height, 0, size.height - radius); - path.lineTo(0, sharpRadius); - path.quadraticBezierTo(0, 0, sharpRadius, 0); - } else { - path.moveTo(radius, 0); - path.lineTo(size.width - sharpRadius, 0); - path.quadraticBezierTo(size.width, 0, size.width, sharpRadius); - path.lineTo(size.width, size.height - radius); - path.quadraticBezierTo( - size.width, - size.height, - size.width - radius, - size.height, - ); - path.lineTo(radius, size.height); - path.quadraticBezierTo(0, size.height, 0, size.height - radius); - path.lineTo(0, radius); - path.quadraticBezierTo(0, 0, radius, 0); - } - path.close(); - - canvas.drawPath(path, paint); - - // Draw border - final borderPaint = Paint() - ..color = borderColor - ..style = PaintingStyle.stroke - ..strokeWidth = 1.5; - canvas.drawPath(path, borderPaint); - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => false; + Widget build(BuildContext context) => + Container(width: 3, color: color.withValues(alpha: 0.9)); } -// --- Sub-Widgets --- - -class _BubbleNote extends StatelessWidget { - final String note; - final ChatTheme theme; - - const _BubbleNote({required this.note, required this.theme}); +/// Square tinted icon chip identifying the category at a glance. +class _CategoryChip extends StatelessWidget { + final IconData icon; + final Color accent; + final double size; + const _CategoryChip({ + required this.icon, + required this.accent, + this.size = 30, + }); @override Widget build(BuildContext context) { - return Text( - note, - style: TextStyle( - color: theme.primaryText, - fontSize: 15, - fontWeight: FontWeight.w600, - height: 1.3, + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(size * 0.33), ), + child: Icon(icon, size: size * 0.55, color: accent), ); } } -class _BubbleAmount extends StatelessWidget { +class _AmountText extends StatelessWidget { final double amount; final TransactionType type; final Color color; final String currency; - - const _BubbleAmount({ + final double fontSize; + const _AmountText({ required this.amount, required this.type, required this.color, required this.currency, + this.fontSize = 25, }); @override Widget build(BuildContext context) { - final symbol = type == TransactionType.incoming - ? '+' - : type == TransactionType.outgoing - ? '-' - : ''; + final sign = switch (type) { + TransactionType.incoming => '+', + TransactionType.outgoing => '-', + TransactionType.invested => '', + }; return Text( - "$symbol$currency${amount.toStringAsFixed(0)}", + "$sign$currency${amount.toStringAsFixed(0)}", style: TextStyle( color: color, - fontSize: 26, + fontSize: fontSize, fontWeight: FontWeight.w800, letterSpacing: -0.5, height: 1.0, + fontFeatures: const [FontFeature.tabularFigures()], ), ); } } -class _BubbleFooter extends StatelessWidget { +class _CategoryPill extends StatelessWidget { final String category; - final DateTime date; - final Color accentColor; + final Color accent; + const _CategoryPill({required this.category, required this.accent}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + "#${category.toLowerCase()}", + style: TextStyle( + color: accent, + fontSize: 10, + fontWeight: FontWeight.bold, + letterSpacing: 0.3, + ), + ), + ); + } +} + +class _CompactRow extends StatelessWidget { final IconData icon; + final double amount; + final TransactionType type; + final Color accent; + final String category; + final String currency; final ChatTheme theme; + const _CompactRow({ + required this.icon, + required this.amount, + required this.type, + required this.accent, + required this.category, + required this.currency, + required this.theme, + }); - const _BubbleFooter({ + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + _CategoryChip(icon: icon, accent: accent, size: 28), + const SizedBox(width: 10), + _AmountText( + amount: amount, + type: type, + color: accent, + currency: currency, + fontSize: 20, + ), + const SizedBox(width: 10), + _CategoryPill(category: category, accent: accent), + ], + ); + } +} + +class _FullBody extends StatelessWidget { + final IconData icon; + final String note; + final double amount; + final TransactionType type; + final Color accent; + final String category; + final DateTime date; + final String currency; + final ChatTheme theme; + const _FullBody({ + required this.icon, + required this.note, + required this.amount, + required this.type, + required this.accent, required this.category, required this.date, - required this.accentColor, - required this.icon, + required this.currency, required this.theme, }); @override Widget build(BuildContext context) { - return Row( + return Column( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: accentColor.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - Icon(icon, size: 10, color: accentColor), - const SizedBox(width: 4), - Text( - category.toUpperCase(), - style: TextStyle( - color: accentColor, - fontSize: 10, - fontWeight: FontWeight.bold, - letterSpacing: 0.3, - ), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _CategoryChip(icon: icon, accent: accent), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + note, + style: TextStyle( + color: theme.primaryText, + fontSize: 15, + fontWeight: FontWeight.w600, + height: 1.25, + ), + ), + const SizedBox(height: 5), + _AmountText( + amount: amount, + type: type, + color: accent, + currency: currency, + ), + ], ), - ], - ), + ), + ], ), - const Spacer(), - Text( - DateFormat('h:mm a').format(date).toLowerCase(), - style: TextStyle( - color: theme.secondaryText, - fontSize: 11, - fontWeight: FontWeight.w500, - ), + const SizedBox(height: 10), + Row( + children: [ + _CategoryPill(category: category, accent: accent), + const Spacer(), + Text( + DateFormat('h:mm a').format(date).toLowerCase(), + style: TextStyle( + color: theme.secondaryText, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ], ), ], ); diff --git a/lib/screens/chat/components/date_header.dart b/lib/screens/chat/components/date_header.dart index 19d7e8a..c4cc0d3 100644 --- a/lib/screens/chat/components/date_header.dart +++ b/lib/screens/chat/components/date_header.dart @@ -2,47 +2,105 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import '../theme/chat_theme.dart'; +import 'glass.dart'; -/// A minimal date header widget displayed between messages from different days. -/// Uses ChatTheme colors for consistent theming. +/// Day divider that doubles as a daily summary chip: +/// "Today · −₹1,240 · 4 txns". Net is signed (incoming +, outgoing/invested −). class DateHeader extends StatelessWidget { final DateTime date; final ChatTheme theme; + final double net; + final int count; + final String currency; - const DateHeader({super.key, required this.date, required this.theme}); + const DateHeader({ + super.key, + required this.date, + required this.theme, + required this.net, + required this.count, + required this.currency, + }); @override Widget build(BuildContext context) { + final sign = net > 0 + ? '+' + : net < 0 + ? '−' + : ''; + final netText = '$sign$currency${net.abs().toStringAsFixed(0)}'; + final netColor = net >= 0 ? theme.incomingAccent : theme.outgoingAccent; + return Center( child: Padding( - padding: const EdgeInsets.symmetric(vertical: 16), - child: Text( - _getLabel(), - style: TextStyle( - color: theme.dateText, - fontSize: 12, - fontWeight: FontWeight.w600, - letterSpacing: 0.3, + padding: const EdgeInsets.symmetric(vertical: 14), + child: Glass( + color: theme.appBarBg, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: theme.patternColor.withValues(alpha: 0.25), + ), + sigma: 6, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _label(), + style: TextStyle( + color: theme.dateText, + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 0.3, + ), + ), + if (count > 0) ...[ + _dot(), + Text( + netText, + style: TextStyle( + color: netColor, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + _dot(), + Text( + '$count ${count == 1 ? "txn" : "txns"}', + style: TextStyle( + color: theme.secondaryText, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ], + ), ), ), ), ); } - String _getLabel() { + Widget _dot() => Padding( + padding: const EdgeInsets.symmetric(horizontal: 7), + child: Text( + '·', + style: TextStyle(color: theme.secondaryText, fontSize: 12), + ), + ); + + String _label() { final now = DateTime.now(); - if (_isSameDay(date, now)) { - return 'Today'; - } else if (_isSameDay(date, now.subtract(const Duration(days: 1)))) { + if (_isSameDay(date, now)) return 'Today'; + if (_isSameDay(date, now.subtract(const Duration(days: 1)))) { return 'Yesterday'; - } else { - return DateFormat('MMMM d, y').format(date); } + return DateFormat('MMMM d, y').format(date); } - bool _isSameDay(DateTime date1, DateTime date2) { - return date1.year == date2.year && - date1.month == date2.month && - date1.day == date2.day; - } + bool _isSameDay(DateTime a, DateTime b) => + a.year == b.year && a.month == b.month && a.day == b.day; } diff --git a/lib/screens/chat/components/glass.dart b/lib/screens/chat/components/glass.dart new file mode 100644 index 0000000..c4eda92 --- /dev/null +++ b/lib/screens/chat/components/glass.dart @@ -0,0 +1,65 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../data/bloc/app_bloc.dart'; + +/// Frosted-glass surface. Uses a real backdrop blur on capable devices and +/// degrades to a flat translucent fill on low-end ones — a long chat list of +/// per-bubble BackdropFilters is expensive on budget phones. One place to +/// tune the look/perf tradeoff for bubbles, the app bar, dividers and input. +class Glass extends StatelessWidget { + final Widget child; + final Color color; + final BorderRadius borderRadius; + final Border? border; + final double sigma; + final List? boxShadow; + + const Glass({ + super.key, + required this.child, + required this.color, + this.borderRadius = const BorderRadius.all(Radius.circular(20)), + this.border, + this.sigma = 9, + this.boxShadow, + }); + + @override + Widget build(BuildContext context) { + final lowEnd = context.select((AppBloc b) => b.isLowEndDevice); + + final decorated = DecoratedBox( + decoration: BoxDecoration( + // Opaque-up the fill when we can't blur, so text stays readable. + color: lowEnd ? _solidify(color) : color, + borderRadius: borderRadius, + border: border, + boxShadow: boxShadow, + ), + child: child, + ); + + if (lowEnd) { + return ClipRRect(borderRadius: borderRadius, child: decorated); + } + + return ClipRRect( + borderRadius: borderRadius, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma), + child: decorated, + ), + ); + } + + /// Pushes a translucent fill toward opaque so it reads without the blur + /// behind it doing the work. + Color _solidify(Color c) { + final a = c.a; + if (a >= 0.85) return c; + return c.withValues(alpha: (a + 0.4).clamp(0.0, 1.0)); + } +} diff --git a/lib/screens/chat/components/glass_app_bar.dart b/lib/screens/chat/components/glass_app_bar.dart index b9301b1..6981d43 100644 --- a/lib/screens/chat/components/glass_app_bar.dart +++ b/lib/screens/chat/components/glass_app_bar.dart @@ -1,10 +1,16 @@ -import 'dart:ui'; - import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../../data/bloc/app_bloc.dart'; +import '../../../data/bloc/expense_bloc.dart'; +import '../../../data/data/expense/expense.dart'; +import '../theme/chat_theme.dart'; import '../theme/chat_theme_provider.dart'; import 'chat_settings_sheet.dart'; +import 'glass.dart'; +/// Chat app bar showing the user's live financial pulse — today's net plus a +/// 7-day sparkline — instead of a static "Active now". Tapping opens settings. class GlassAppBar extends StatelessWidget { final ChatThemeProvider themeProvider; @@ -13,150 +19,198 @@ class GlassAppBar extends StatelessWidget { @override Widget build(BuildContext context) { final theme = themeProvider.theme; + final bloc = context.watch(); + final currency = context.watch().currency; + + final daily = _dailyNet(bloc.expenses, 7); + final todayNet = daily.isNotEmpty ? daily.last : 0.0; - return ClipRRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), - child: Container( - padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top), - decoration: BoxDecoration( - color: theme.appBarBg, - border: Border( - bottom: BorderSide( - color: theme.patternColor.withValues(alpha: 0.2), - width: 0.5, + final sign = todayNet > 0 + ? '+' + : todayNet < 0 + ? '−' + : ''; + final todayText = todayNet == 0 + ? 'No spending today' + : '$sign$currency${todayNet.abs().toStringAsFixed(0)} today'; + final todayColor = todayNet >= 0 ? theme.statusDot : theme.outgoingAccent; + + return Glass( + color: theme.appBarBg, + borderRadius: BorderRadius.zero, + border: Border( + bottom: BorderSide( + color: theme.patternColor.withValues(alpha: 0.2), + width: 0.5, + ), + ), + sigma: 20, + child: Container( + padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top), + child: SizedBox( + height: 60, + child: Row( + children: [ + IconButton( + onPressed: () => Navigator.pop(context), + icon: _chip(theme, Icons.arrow_back_ios_new_rounded, 16), ), - ), - ), - child: SizedBox( - height: 60, - child: Row( - children: [ - // Back button - IconButton( - onPressed: () => Navigator.pop(context), - icon: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: theme.patternColor, - borderRadius: BorderRadius.circular(10), + const SizedBox(width: 4), + GestureDetector( + onTap: () => ChatSettingsSheet.show(context, themeProvider), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + theme.statusDot.withValues(alpha: 0.25), + theme.statusDot.withValues(alpha: 0.15), + ], ), - child: Icon( - Icons.arrow_back_ios_new_rounded, - size: 16, - color: theme.appBarText, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: theme.statusDot.withValues(alpha: 0.4), + width: 1.5, ), ), - ), - - const SizedBox(width: 4), - - // Avatar - tap to open settings - GestureDetector( - onTap: () => ChatSettingsSheet.show(context, themeProvider), - child: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - theme.statusDot.withValues(alpha: 0.25), - theme.statusDot.withValues(alpha: 0.15), - ], - ), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: theme.statusDot.withValues(alpha: 0.4), - width: 1.5, - ), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Image.asset( - 'logo-big.png', - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => Icon( - Icons.account_balance_wallet_rounded, - color: theme.statusDot, - size: 20, - ), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.asset( + 'logo-big.png', + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => Icon( + Icons.account_balance_wallet_rounded, + color: theme.statusDot, + size: 20, ), ), ), ), - - const SizedBox(width: 12), - - // Name and status - tap to open settings - Expanded( - child: GestureDetector( - onTap: () => ChatSettingsSheet.show(context, themeProvider), - behavior: HitTestBehavior.opaque, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Clean Expense', - style: TextStyle( - color: theme.appBarText, - fontSize: 16, - fontWeight: FontWeight.w700, - letterSpacing: -0.3, - ), + ), + const SizedBox(width: 12), + Expanded( + child: GestureDetector( + onTap: () => ChatSettingsSheet.show(context, themeProvider), + behavior: HitTestBehavior.opaque, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Your money', + style: TextStyle( + color: theme.appBarText, + fontSize: 16, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, ), - const SizedBox(height: 2), - Row( - children: [ - Container( - width: 7, - height: 7, - decoration: BoxDecoration( - color: theme.statusDot, - shape: BoxShape.circle, - ), + ), + const SizedBox(height: 3), + Row( + children: [ + Text( + todayText, + style: TextStyle( + color: todayColor, + fontSize: 11, + fontWeight: FontWeight.w600, ), - const SizedBox(width: 5), - Text( - 'Active now', - style: TextStyle( - color: theme.secondaryText, - fontSize: 11, - fontWeight: FontWeight.w500, + ), + if (daily.any((v) => v != 0)) ...[ + const SizedBox(width: 8), + SizedBox( + width: 46, + height: 16, + child: CustomPaint( + painter: _SparklinePainter( + values: daily, + color: theme.statusDot, + ), ), ), ], - ), - ], - ), - ), - ), - - // Settings button - IconButton( - onPressed: () => - ChatSettingsSheet.show(context, themeProvider), - icon: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: theme.patternColor, - borderRadius: BorderRadius.circular(10), - ), - child: Icon( - Icons.tune_rounded, - size: 18, - color: theme.appBarText, - ), + ], + ), + ], ), ), - const SizedBox(width: 4), - ], - ), + ), + IconButton( + onPressed: () => + ChatSettingsSheet.show(context, themeProvider), + icon: _chip(theme, Icons.tune_rounded, 18), + ), + const SizedBox(width: 4), + ], ), ), ), ); } + + Widget _chip(ChatTheme theme, IconData icon, double size) { + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: theme.patternColor, + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, size: size, color: theme.appBarText), + ); + } + + /// Net flow (incoming − outgoing − invested) for each of the last [days] + /// days, oldest first. + List _dailyNet(List expenses, int days) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final buckets = List.filled(days, 0); + for (final e in expenses) { + final d = DateTime(e.date.year, e.date.month, e.date.day); + final diff = today.difference(d).inDays; + if (diff < 0 || diff >= days) continue; + final idx = days - 1 - diff; + buckets[idx] += e.type == TransactionType.incoming ? e.amount : -e.amount; + } + return buckets; + } +} + +class _SparklinePainter extends CustomPainter { + final List values; + final Color color; + + _SparklinePainter({required this.values, required this.color}); + + @override + void paint(Canvas canvas, Size size) { + if (values.length < 2) return; + final minV = values.reduce((a, b) => a < b ? a : b); + final maxV = values.reduce((a, b) => a > b ? a : b); + final range = (maxV - minV).abs() < 1e-6 ? 1.0 : (maxV - minV); + + final path = Path(); + for (var i = 0; i < values.length; i++) { + final x = size.width * i / (values.length - 1); + final y = size.height - ((values[i] - minV) / range) * size.height; + i == 0 ? path.moveTo(x, y) : path.lineTo(x, y); + } + + canvas.drawPath( + path, + Paint() + ..color = color.withValues(alpha: 0.8) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.6 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round, + ); + } + + @override + bool shouldRepaint(_SparklinePainter old) => + old.values != values || old.color != color; } diff --git a/lib/screens/chat/components/insight_bubble.dart b/lib/screens/chat/components/insight_bubble.dart new file mode 100644 index 0000000..1c9c3cf --- /dev/null +++ b/lib/screens/chat/components/insight_bubble.dart @@ -0,0 +1,155 @@ +import 'package:flutter/material.dart'; + +import '../../../data/data/insight/insight.dart'; +import '../theme/chat_theme.dart'; + +/// The "your money" message — an app-authored insight in the thread. +/// Deliberately NOT glass: a solid accent-tinted card so it reads as the app +/// speaking, distinct from the user's own transaction bubbles. +class InsightBubble extends StatelessWidget { + final InsightData insight; + final ChatTheme theme; + + const InsightBubble({super.key, required this.insight, required this.theme}); + + @override + Widget build(BuildContext context) { + final accent = theme.insightAccent; + + return Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 16), + child: Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.82, + ), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.10), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(5), + topRight: Radius.circular(20), + bottomLeft: Radius.circular(20), + bottomRight: Radius.circular(20), + ), + border: Border.all(color: accent.withValues(alpha: 0.30)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: accent, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.auto_awesome_rounded, + size: 14, + color: Colors.white, + ), + ), + const SizedBox(width: 8), + Text( + 'YOUR MONEY', + style: TextStyle( + color: accent, + fontSize: 11, + fontWeight: FontWeight.bold, + letterSpacing: 0.4, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + insight.text, + style: TextStyle( + color: theme.primaryText, + fontSize: 13.5, + height: 1.4, + fontWeight: FontWeight.w500, + ), + ), + if (insight.bars.isNotEmpty) ...[ + const SizedBox(height: 11), + _MiniBars(bars: insight.bars, accent: accent, theme: theme), + ], + ], + ), + ), + ), + ); + } +} + +class _MiniBars extends StatelessWidget { + final List bars; + final Color accent; + final ChatTheme theme; + const _MiniBars({ + required this.bars, + required this.accent, + required this.theme, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + for (final bar in bars) + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + children: [ + SizedBox( + width: 64, + child: Text( + '#${bar.label}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11, + color: theme.secondaryText, + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: LayoutBuilder( + builder: (context, c) => Stack( + children: [ + Container( + height: 8, + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(4), + ), + ), + Container( + height: 8, + width: c.maxWidth * bar.fraction.clamp(0.05, 1.0), + decoration: BoxDecoration( + color: accent.withValues( + alpha: 0.4 + 0.6 * bar.fraction.clamp(0.0, 1.0), + ), + borderRadius: BorderRadius.circular(4), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/screens/chat/components/transaction_list.dart b/lib/screens/chat/components/transaction_list.dart index a524d83..ddca7b2 100644 --- a/lib/screens/chat/components/transaction_list.dart +++ b/lib/screens/chat/components/transaction_list.dart @@ -4,87 +4,119 @@ import 'package:provider/provider.dart'; import '../../../data/bloc/app_bloc.dart'; import '../../../data/bloc/expense_bloc.dart'; +import '../../../data/bloc/insight_bloc.dart'; import '../../../data/data/expense/expense.dart'; +import '../../../data/data/insight/insight.dart'; import '../state/chat_interaction_provider.dart'; import '../theme/chat_theme.dart'; import '../theme/chat_theme_provider.dart'; import 'chat_bubble.dart'; import 'date_header.dart'; +import 'insight_bubble.dart'; + +/// One row in the chat thread: a transaction or an app-authored insight. +class _ChatItem { + final DateTime date; + final ExpenseData? expense; + final InsightData? insight; + _ChatItem.expense(this.expense) : date = expense!.date, insight = null; + _ChatItem.insight(this.insight) : date = insight!.date, expense = null; + bool get isInsight => insight != null; +} + +/// Per-day rollup shown in the date divider (expenses only). +class _DaySummary { + double net = 0; + int count = 0; +} -/// A sliver list that displays transactions with staggered animations. +/// A sliver list that interleaves transactions and "your money" insights, +/// grouped by day with summary dividers. class TransactionList extends StatelessWidget { const TransactionList({super.key}); @override Widget build(BuildContext context) { final theme = context.watch().theme; + final currency = context.watch().currency; + final expenseBloc = context.watch(); + final insightBloc = context.watch(); + + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day, 23, 59, 59); + + final expenses = expenseBloc.expenses + .where((e) => e.date.isBefore(today) || _isSameDay(e.date, now)) + .toList(); + + if (expenses.isEmpty && insightBloc.feed.isEmpty) { + return SliverFillRemaining( + hasScrollBody: false, + child: _EmptyState(theme: theme), + ); + } + + // Per-day rollup from expenses (insights don't count toward spend). + final summaries = {}; + for (final e in expenses) { + final s = summaries.putIfAbsent(_dayKey(e.date), () => _DaySummary()); + s.net += e.type == TransactionType.incoming ? e.amount : -e.amount; + s.count++; + } - return Consumer( - builder: (context, bloc, child) { - final now = DateTime.now(); - final today = DateTime(now.year, now.month, now.day, 23, 59, 59); - - // Filter out future dated expenses and sort by date descending - final expenses = - bloc.expenses - .where((e) => e.date.isBefore(today) || _isSameDay(e.date, now)) - .toList() - ..sort((a, b) => b.date.compareTo(a.date)); - - if (expenses.isEmpty) { - return SliverFillRemaining( - hasScrollBody: false, - child: _EmptyState(theme: theme), + final items = <_ChatItem>[ + ...expenses.map(_ChatItem.expense), + ...insightBloc.feed.map(_ChatItem.insight), + ]..sort((a, b) => b.date.compareTo(a.date)); + + return SliverPadding( + padding: const EdgeInsets.only(bottom: 120, top: 8), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final item = items[index]; + final isLast = index == items.length - 1; + // Header sits at a day boundary (next item is older / a new day), + // matching the reversed scroll so it caps the day's group. + final showHeader = + isLast || !_isSameDay(item.date, items[index + 1].date); + final summary = summaries[_dayKey(item.date)]; + + return _AnimatedTransactionItem( + index: index, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (showHeader) + DateHeader( + date: item.date, + theme: theme, + net: summary?.net ?? 0, + count: summary?.count ?? 0, + currency: currency, + ), + if (item.isInsight) + InsightBubble(insight: item.insight!, theme: theme) + else + _TransactionBubble( + expense: item.expense!, + theme: theme, + currency: currency, + ), + ], + ), ); - } - - return SliverPadding( - padding: const EdgeInsets.only(bottom: 120, top: 8), - sliver: SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final expense = expenses[index]; - final isLastItem = index == expenses.length - 1; - - bool showDateHeader = false; - if (isLastItem) { - showDateHeader = true; - } else { - final nextExpense = expenses[index + 1]; - if (!_isSameDay(expense.date, nextExpense.date)) { - showDateHeader = true; - } - } - - return _AnimatedTransactionItem( - index: index, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (showDateHeader) - DateHeader(date: expense.date, theme: theme), - _TransactionBubble( - expense: expense, - theme: theme, - currency: context.watch().currency, - ), - ], - ), - ); - }, childCount: expenses.length), - ), - ); - }, + }, childCount: items.length), + ), ); } - bool _isSameDay(DateTime date1, DateTime date2) { - return date1.year == date2.year && - date1.month == date2.month && - date1.day == date2.day; - } + static String _dayKey(DateTime d) => '${d.year}-${d.month}-${d.day}'; + + bool _isSameDay(DateTime a, DateTime b) => + a.year == b.year && a.month == b.month && a.day == b.day; } -/// Animated wrapper for transaction items with staggered entrance. +/// Animated wrapper for thread items with staggered entrance. class _AnimatedTransactionItem extends StatefulWidget { final int index; final Widget child; diff --git a/lib/screens/chat/theme/chat_theme.dart b/lib/screens/chat/theme/chat_theme.dart index 917d4b2..095f628 100644 --- a/lib/screens/chat/theme/chat_theme.dart +++ b/lib/screens/chat/theme/chat_theme.dart @@ -41,6 +41,10 @@ class ChatTheme { final Color secondaryText; final Color dateText; + // "Your money" insight bubble accent. Defaulted so the 6 existing themes + // need no edits; override per-theme to fine-tune. + final Color insightAccent; + const ChatTheme({ required this.id, required this.name, @@ -65,6 +69,7 @@ class ChatTheme { required this.primaryText, required this.secondaryText, required this.dateText, + this.insightAccent = const Color(0xFF6C63FF), }); } @@ -210,6 +215,8 @@ class ChatThemes { primaryText: Color(0xFF3B0764), secondaryText: Color(0xFF8B7798), dateText: Color(0x80581C87), + // Teal so the insight bubble stands apart from this purple theme. + insightAccent: Color(0xFF0D9488), ); /// Midnight Theme - Dark mode with vibrant accents @@ -245,6 +252,8 @@ class ChatThemes { primaryText: Color(0xFFF1F5F9), secondaryText: Color(0xFF94A3B8), dateText: Color(0x80CBD5E1), + // Brighter violet reads better on the deep-navy background. + insightAccent: Color(0xFFA78BFA), ); /// Carbon Theme - Pure dark with warm accents @@ -280,6 +289,8 @@ class ChatThemes { primaryText: Color(0xFFF5F5F5), secondaryText: Color(0xFF737373), dateText: Color(0x70A3A3A3), + // Brighter violet for contrast on true black. + insightAccent: Color(0xFFA78BFA), ); /// All available themes diff --git a/test/insight_test.dart b/test/insight_test.dart new file mode 100644 index 0000000..67336d2 --- /dev/null +++ b/test/insight_test.dart @@ -0,0 +1,100 @@ +import 'package:expense/data/data/expense/expense.dart'; +import 'package:expense/data/data/insight/insight.dart'; +import 'package:expense/data/utils/ai/insight_generator.dart'; +import 'package:expense/data/utils/category_style.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +ExpenseData _e( + String id, + double amount, + String category, + DateTime date, { + TransactionType type = TransactionType.outgoing, +}) => ExpenseData( + id: id, + amount: amount, + category: category, + date: date, + type: type, + note: '', +); + +void main() { + group('CategoryStyle', () { + test('known categories map to distinct icons', () { + expect(CategoryStyle.iconFor('food'), isNot(CategoryStyle.iconFor('rent'))); + expect(CategoryStyle.iconFor('FOOD'), CategoryStyle.iconFor('food')); + }); + + test('unknown categories are deterministic', () { + final a = CategoryStyle.iconFor('my-custom-cat'); + final b = CategoryStyle.iconFor('my-custom-cat'); + expect(a, b); + expect(a, isA()); + }); + }); + + group('InsightGenerator', () { + final now = DateTime(2026, 6, 12, 10); + + test('returns null with no expenses', () { + expect( + InsightGenerator.generate(expenses: const [], now: now, currency: '₹'), + isNull, + ); + }); + + test('daily recap fires from yesterday spending, names top category', () { + final yesterday = now.subtract(const Duration(days: 1)); + final insight = InsightGenerator.generate( + expenses: [ + _e('1', 500, 'food', yesterday), + _e('2', 120, 'transport', yesterday), + ], + now: now, + currency: '₹', + ); + expect(insight, isNotNull); + expect(insight!.kind, InsightKind.dailyRecap); + expect(insight.category, 'food'); + expect(insight.text, contains('620')); + expect(insight.id, contains('20260612')); + }); + + test('falls through to top-category-week when no yesterday spend', () { + final insight = InsightGenerator.generate( + expenses: [ + _e('1', 800, 'food', now), // today, this week, no yesterday + _e('2', 200, 'coffee', now), + ], + now: now, + currency: '₹', + ); + expect(insight, isNotNull); + expect(insight!.kind, InsightKind.topCategoryWeek); + expect(insight.bars, isNotEmpty); + }); + }); + + group('InsightData JSON', () { + test('round-trips including bars and category', () { + const original = InsightData( + id: 'topCategoryWeek_20260612', + createdAt: 1781000000000, + kind: InsightKind.topCategoryWeek, + text: 'This week #food is your top category.', + bars: [InsightBar('food', 1.0), InsightBar('coffee', 0.4)], + category: 'food', + ); + final restored = InsightData.fromJson(original.toJson()); + expect(restored.id, original.id); + expect(restored.kind, original.kind); + expect(restored.text, original.text); + expect(restored.category, 'food'); + expect(restored.bars.length, 2); + expect(restored.bars.first.label, 'food'); + expect(restored.bars.first.fraction, 1.0); + }); + }); +}