diff --git a/assets/fonts/WorkSans-Bold.ttf b/assets/fonts/WorkSans-Bold.ttf new file mode 100644 index 0000000..ce8fe08 Binary files /dev/null and b/assets/fonts/WorkSans-Bold.ttf differ diff --git a/assets/fonts/WorkSans-Medium.ttf b/assets/fonts/WorkSans-Medium.ttf new file mode 100644 index 0000000..112d3a4 Binary files /dev/null and b/assets/fonts/WorkSans-Medium.ttf differ diff --git a/assets/fonts/WorkSans-Regular.ttf b/assets/fonts/WorkSans-Regular.ttf new file mode 100644 index 0000000..b26ace6 Binary files /dev/null and b/assets/fonts/WorkSans-Regular.ttf differ diff --git a/assets/fonts/WorkSans-SemiBold.ttf b/assets/fonts/WorkSans-SemiBold.ttf new file mode 100644 index 0000000..a064002 Binary files /dev/null and b/assets/fonts/WorkSans-SemiBold.ttf differ diff --git a/docs/progress.md b/docs/progress.md index 52b4a1f..d377205 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -1,6 +1,56 @@ # Relationship Saver Progress Log -Updated: 2026-02-14 +Updated: 2026-02-15 + +## Latest Milestone (2026-02-15): Product UI Flow Implemented + +The app now has a real multi-screen user experience on macOS (and other platforms), not just a placeholder screen. + +Delivered UI architecture: + +- App shell with responsive navigation: + - desktop: left sidebar + content pane + - compact: bottom navigation +- Views implemented: + - Dashboard + - People + - Moments + - Signals + - Sync + - Settings +- Reference-aligned visual style: + - WorkSans typography (copied from the UI reference assets) + - soft gradients, rounded glass-like cards, accent palette similar to the reference repo +- Offline-safe UX: + - fake backend mode now defaults to `true` unless overridden via `--dart-define=USE_FAKE_BACKEND=false` + - signals view gracefully falls back to local recommendations when backend calls fail + +Key new files: + +- `lib/features/home/app_shell.dart` +- `lib/features/dashboard/dashboard_view.dart` +- `lib/features/people/people_view.dart` +- `lib/features/moments/moments_view.dart` +- `lib/features/signals/signals_controller.dart` +- `lib/features/signals/signals_view.dart` +- `lib/features/sync/sync_view.dart` +- `lib/features/settings/settings_view.dart` +- `lib/features/shared/frosted_card.dart` +- `lib/features/local/local_models.dart` +- `lib/features/local/local_repository.dart` +- `assets/fonts/WorkSans-*.ttf` + +Other updates: + +- `lib/main.dart` now launches the new app shell. +- `lib/core/config/app_theme.dart` updated to a stronger visual system. +- `pubspec.yaml` includes local WorkSans font family. +- `test/widget_test.dart` updated for the new flow. + +Validation status after UI milestone: + +- `flutter analyze` -> pass +- `flutter test` -> pass ## Current Status @@ -165,10 +215,10 @@ Most recent local checks: ### Priority 3: App Wiring + UX -1. Wire `BackendGateway` into app services/use cases (not UI widgets directly). -2. Add session state provider (signed in / expired / signed out). -3. Add explicit offline indicators and non-blocking sync status UI. -4. Start consuming fake gateway in feature screens before live backend. +1. Replace local demo repository data with actual local DB repositories. +2. Add create/edit flows for people, captures, reminders, and ideas (forms + validation). +3. Add session state and auth entry screens (sign-in, expired-session recovery). +4. Add explicit offline badges and sync conflict resolution UI. ### Priority 4: Security + Hardening diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index 80caec0..5f2b43d 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -20,7 +20,7 @@ class AppConfig { /// Enables fake backend implementation for local/offline development. static bool get useFakeBackend => - const bool.fromEnvironment('USE_FAKE_BACKEND', defaultValue: false); + const bool.fromEnvironment('USE_FAKE_BACKEND', defaultValue: true); /// Runtime override for backend URL (e.g. local settings screen). static void overrideBackendBaseUrl(String? baseUrl) { diff --git a/lib/core/config/app_theme.dart b/lib/core/config/app_theme.dart index f4244de..290b2e7 100644 --- a/lib/core/config/app_theme.dart +++ b/lib/core/config/app_theme.dart @@ -4,31 +4,55 @@ import 'package:flutter/material.dart'; class AppTheme { AppTheme._(); - static const Color background = Color(0xFFEDF0F2); + static const Color background = Color(0xFFF3F6F8); static const Color surface = Color(0xFFFFFFFF); - static const Color primary = Color(0xFF253840); - static const Color accent = Color(0xFF00B6F0); + static const Color primary = Color(0xFF1E3541); + static const Color accent = Color(0xFF1AB6C8); + static const Color textPrimary = Color(0xFF18303A); + static const Color textSecondary = Color(0xFF58707A); /// Builds the Material [ThemeData]. static ThemeData light() { - final ColorScheme colorScheme = ColorScheme.fromSeed( - seedColor: primary, - primary: primary, - secondary: accent, - surface: surface, - brightness: Brightness.light, + final ColorScheme colorScheme = ColorScheme.fromSeed(seedColor: primary); + final TextTheme textTheme = ThemeData.light().textTheme.apply( + fontFamily: 'WorkSans', + bodyColor: textPrimary, + displayColor: textPrimary, ); return ThemeData( useMaterial3: true, - colorScheme: colorScheme, + colorScheme: colorScheme.copyWith( + primary: primary, + secondary: accent, + surface: surface, + ), scaffoldBackgroundColor: background, + textTheme: textTheme.copyWith( + headlineMedium: textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: 0.1, + ), + titleLarge: textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: 0.1, + ), + titleMedium: textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), appBarTheme: const AppBarTheme( centerTitle: false, - backgroundColor: surface, - foregroundColor: primary, + backgroundColor: Colors.transparent, + foregroundColor: textPrimary, elevation: 0, ), + cardTheme: CardThemeData( + color: surface, + elevation: 0, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + ), ); } } diff --git a/lib/features/dashboard/dashboard_view.dart b/lib/features/dashboard/dashboard_view.dart new file mode 100644 index 0000000..97c7717 --- /dev/null +++ b/lib/features/dashboard/dashboard_view.dart @@ -0,0 +1,199 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/core/config/app_theme.dart'; +import 'package:relationship_saver/features/local/local_models.dart'; +import 'package:relationship_saver/features/local/local_repository.dart'; +import 'package:relationship_saver/features/shared/frosted_card.dart'; + +class DashboardView extends ConsumerWidget { + const DashboardView({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final LocalRepository repository = ref.watch(localRepositoryProvider); + final DashboardSummary summary = repository.summary(); + final List tasks = repository.tasks(); + + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(28, 24, 28, 36), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Today', style: Theme.of(context).textTheme.headlineMedium), + const SizedBox(height: 8), + Text( + 'Focus on consistency over intensity. Small moments compound.', + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 22), + Wrap( + spacing: 16, + runSpacing: 16, + children: [ + _StatCard( + label: 'Active People', + value: '${summary.activePeople}', + accent: const Color(0xFF16B8CA), + ), + _StatCard( + label: 'Upcoming Plans', + value: '${summary.upcomingPlans}', + accent: const Color(0xFF2B7FFF), + ), + _StatCard( + label: 'Pending Ideas', + value: '${summary.pendingIdeas}', + accent: const Color(0xFFFFA447), + ), + _StatCard( + label: 'Weekly Rhythm', + value: '${summary.weeklyConsistency}%', + accent: const Color(0xFF30B66A), + ), + ], + ), + const SizedBox(height: 24), + FrostedCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Next Moves', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 14), + ...tasks.map((DashboardTask task) => _TaskRow(task: task)), + ], + ), + ), + ], + ), + ); + } +} + +class _StatCard extends StatelessWidget { + const _StatCard({ + required this.label, + required this.value, + required this.accent, + }); + + final String label; + final String value; + final Color accent; + + @override + Widget build(BuildContext context) { + return FrostedCard( + padding: const EdgeInsets.all(16), + child: SizedBox( + width: 188, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 8, + width: 40, + decoration: BoxDecoration( + color: accent, + borderRadius: BorderRadius.circular(99), + ), + ), + const SizedBox(height: 16), + Text( + value, + style: Theme.of( + context, + ).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 4), + Text( + label, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), + ), + ], + ), + ), + ); + } +} + +class _TaskRow extends StatelessWidget { + const _TaskRow({required this.task}); + + final DashboardTask task; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFF5FAFB), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + margin: const EdgeInsets.only(top: 2), + width: 10, + height: 10, + decoration: const BoxDecoration( + color: Color(0xFF1AB6C8), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + task.title, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text( + task.description, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Text( + _shortDate(task.when), + style: Theme.of( + context, + ).textTheme.labelLarge?.copyWith(color: AppTheme.textSecondary), + ), + ], + ), + ), + ); + } +} + +String _shortDate(DateTime value) { + const List weekdays = [ + 'Mon', + 'Tue', + 'Wed', + 'Thu', + 'Fri', + 'Sat', + 'Sun', + ]; + final String weekday = weekdays[value.weekday - 1]; + final String day = value.day.toString().padLeft(2, '0'); + return '$weekday $day'; +} diff --git a/lib/features/home/app_shell.dart b/lib/features/home/app_shell.dart new file mode 100644 index 0000000..cf80c6a --- /dev/null +++ b/lib/features/home/app_shell.dart @@ -0,0 +1,268 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/core/config/app_theme.dart'; +import 'package:relationship_saver/features/dashboard/dashboard_view.dart'; +import 'package:relationship_saver/features/moments/moments_view.dart'; +import 'package:relationship_saver/features/people/people_view.dart'; +import 'package:relationship_saver/features/settings/settings_view.dart'; +import 'package:relationship_saver/features/signals/signals_view.dart'; +import 'package:relationship_saver/features/sync/sync_view.dart'; + +class AppShell extends ConsumerStatefulWidget { + const AppShell({super.key}); + + @override + ConsumerState createState() => _AppShellState(); +} + +class _AppShellState extends ConsumerState { + int _index = 0; + + static const List<_ShellDestination> _destinations = <_ShellDestination>[ + _ShellDestination('Dashboard', Icons.dashboard_rounded), + _ShellDestination('People', Icons.people_alt_rounded), + _ShellDestination('Moments', Icons.auto_awesome_rounded), + _ShellDestination('Signals', Icons.tips_and_updates_rounded), + _ShellDestination('Sync', Icons.sync_rounded), + _ShellDestination('Settings', Icons.settings_rounded), + ]; + + @override + Widget build(BuildContext context) { + final Widget body = _screenFor(_index); + + return DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFFF3F8FB), + Color(0xFFEAF1F8), + Color(0xFFF8FAFC), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Scaffold( + backgroundColor: Colors.transparent, + body: SafeArea( + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + if (constraints.maxWidth < 1000) { + return _CompactShell( + index: _index, + body: body, + destinations: _destinations, + onChange: (int value) { + setState(() { + _index = value; + }); + }, + ); + } + + return Row( + children: [ + _Sidebar( + index: _index, + destinations: _destinations, + onChange: (int value) { + setState(() { + _index = value; + }); + }, + ), + Expanded( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + child: KeyedSubtree( + key: ValueKey(_index), + child: body, + ), + ), + ), + ], + ); + }, + ), + ), + ), + ); + } + + Widget _screenFor(int index) { + switch (index) { + case 0: + return const DashboardView(); + case 1: + return const PeopleView(); + case 2: + return const MomentsView(); + case 3: + return const SignalsView(); + case 4: + return const SyncView(); + case 5: + return const SettingsView(); + default: + return const DashboardView(); + } + } +} + +class _Sidebar extends StatelessWidget { + const _Sidebar({ + required this.index, + required this.destinations, + required this.onChange, + }); + + final int index; + final List<_ShellDestination> destinations; + final ValueChanged onChange; + + @override + Widget build(BuildContext context) { + return Container( + width: 290, + margin: const EdgeInsets.fromLTRB(20, 20, 8, 20), + padding: const EdgeInsets.fromLTRB(18, 20, 18, 18), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.78), + borderRadius: BorderRadius.circular(28), + border: Border.all(color: Colors.white.withValues(alpha: 0.7)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + gradient: const LinearGradient( + colors: [Color(0xFF1AB6C8), Color(0xFF286DE0)], + ), + ), + child: const Icon( + Icons.favorite_rounded, + color: Colors.white, + size: 20, + ), + ), + const SizedBox(width: 10), + Text( + 'Relationship Saver', + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + const SizedBox(height: 22), + ...destinations.indexed.map(((int, _ShellDestination) tuple) { + final int destinationIndex = tuple.$1; + final _ShellDestination destination = tuple.$2; + final bool selected = destinationIndex == index; + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: InkWell( + onTap: () => onChange(destinationIndex), + borderRadius: BorderRadius.circular(14), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + decoration: BoxDecoration( + color: selected + ? const Color(0xFFEAF7FB) + : Colors.transparent, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + Icon( + destination.icon, + size: 20, + color: selected + ? AppTheme.primary + : AppTheme.textSecondary, + ), + const SizedBox(width: 10), + Text( + destination.label, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith( + color: selected + ? AppTheme.primary + : AppTheme.textSecondary, + ), + ), + ], + ), + ), + ), + ); + }), + const Spacer(), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF2F8FB), + borderRadius: BorderRadius.circular(16), + ), + child: Text( + 'Tip: Use Signals daily and convert at least one into a concrete plan.', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), + ), + ), + ], + ), + ); + } +} + +class _CompactShell extends StatelessWidget { + const _CompactShell({ + required this.index, + required this.body, + required this.destinations, + required this.onChange, + }); + + final int index; + final Widget body; + final List<_ShellDestination> destinations; + final ValueChanged onChange; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Expanded(child: body), + NavigationBar( + selectedIndex: index, + onDestinationSelected: onChange, + destinations: destinations + .map( + (_ShellDestination destination) => NavigationDestination( + icon: Icon(destination.icon), + label: destination.label, + ), + ) + .toList(growable: false), + ), + ], + ); + } +} + +class _ShellDestination { + const _ShellDestination(this.label, this.icon); + + final String label; + final IconData icon; +} diff --git a/lib/features/local/local_models.dart b/lib/features/local/local_models.dart new file mode 100644 index 0000000..de42ba3 --- /dev/null +++ b/lib/features/local/local_models.dart @@ -0,0 +1,65 @@ +class PersonProfile { + const PersonProfile({ + required this.id, + required this.name, + required this.relationship, + required this.affinityScore, + required this.nextMoment, + required this.tags, + required this.notes, + }); + + final String id; + final String name; + final String relationship; + final int affinityScore; + final DateTime nextMoment; + final List tags; + final String notes; +} + +class RelationshipMoment { + const RelationshipMoment({ + required this.id, + required this.personId, + required this.title, + required this.summary, + required this.at, + required this.type, + }); + + final String id; + final String personId; + final String title; + final String summary; + final DateTime at; + final String type; +} + +class DashboardTask { + const DashboardTask({ + required this.title, + required this.description, + required this.when, + this.done = false, + }); + + final String title; + final String description; + final DateTime when; + final bool done; +} + +class DashboardSummary { + const DashboardSummary({ + required this.activePeople, + required this.upcomingPlans, + required this.pendingIdeas, + required this.weeklyConsistency, + }); + + final int activePeople; + final int upcomingPlans; + final int pendingIdeas; + final int weeklyConsistency; +} diff --git a/lib/features/local/local_repository.dart b/lib/features/local/local_repository.dart new file mode 100644 index 0000000..fca318f --- /dev/null +++ b/lib/features/local/local_repository.dart @@ -0,0 +1,105 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/features/local/local_models.dart'; + +class LocalRepository { + const LocalRepository(); + + List people() { + return [ + PersonProfile( + id: 'p-ava', + name: 'Ava Hart', + relationship: 'Partner', + affinityScore: 92, + nextMoment: DateTime(2026, 2, 16, 19, 30), + tags: ['coffee', 'books', 'slow mornings'], + notes: 'Prefers thoughtful plans over expensive plans.', + ), + PersonProfile( + id: 'p-jordan', + name: 'Jordan Lee', + relationship: 'Close Friend', + affinityScore: 78, + nextMoment: DateTime(2026, 2, 18, 18, 0), + tags: ['running', 'street food'], + notes: 'Has a race on Sunday; ask how training is going.', + ), + PersonProfile( + id: 'p-mila', + name: 'Mila Stone', + relationship: 'Sister', + affinityScore: 84, + nextMoment: DateTime(2026, 2, 20, 12, 0), + tags: ['plants', 'retro music'], + notes: 'Birthday prep should start this week.', + ), + ]; + } + + List moments() { + return [ + RelationshipMoment( + id: 'm-1', + personId: 'p-ava', + title: 'Sunday Walk Tradition', + summary: 'Short walk + no-phone hour worked really well.', + at: DateTime(2026, 2, 9, 9, 30), + type: 'ritual', + ), + RelationshipMoment( + id: 'm-2', + personId: 'p-jordan', + title: 'Post-workout Check-in', + summary: 'Shared training playlist and grabbed smoothies.', + at: DateTime(2026, 2, 11, 19, 0), + type: 'support', + ), + RelationshipMoment( + id: 'm-3', + personId: 'p-mila', + title: 'Gift Idea Captured', + summary: 'Found a vintage lamp from her saved style board.', + at: DateTime(2026, 2, 12, 14, 20), + type: 'gift', + ), + ]; + } + + List tasks() { + return [ + DashboardTask( + title: 'Plan Friday dinner with Ava', + description: 'Keep it low-key: ramen + bookstore stop.', + when: DateTime(2026, 2, 16, 17, 0), + ), + DashboardTask( + title: 'Send race-day encouragement to Jordan', + description: 'Voice note before 8:00 AM.', + when: DateTime(2026, 2, 17, 7, 30), + ), + DashboardTask( + title: 'Finalize Mila birthday shortlist', + description: 'Pick 1 meaningful gift and 1 backup.', + when: DateTime(2026, 2, 18, 20, 0), + ), + ]; + } + + DashboardSummary summary() { + return DashboardSummary( + activePeople: people().length, + upcomingPlans: people() + .where( + (PersonProfile p) => p.nextMoment.isAfter(DateTime(2026, 2, 14)), + ) + .length, + pendingIdeas: 6, + weeklyConsistency: 82, + ); + } +} + +final Provider localRepositoryProvider = + Provider((Ref ref) { + return const LocalRepository(); + }); diff --git a/lib/features/moments/moments_view.dart b/lib/features/moments/moments_view.dart new file mode 100644 index 0000000..969c584 --- /dev/null +++ b/lib/features/moments/moments_view.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/core/config/app_theme.dart'; +import 'package:relationship_saver/features/local/local_models.dart'; +import 'package:relationship_saver/features/local/local_repository.dart'; +import 'package:relationship_saver/features/shared/frosted_card.dart'; + +class MomentsView extends ConsumerWidget { + const MomentsView({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final LocalRepository repository = ref.watch(localRepositoryProvider); + final List moments = repository.moments(); + final Map peopleById = { + for (final PersonProfile person in repository.people()) person.id: person, + }; + + return Padding( + padding: const EdgeInsets.fromLTRB(28, 24, 28, 28), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Moments', style: Theme.of(context).textTheme.headlineMedium), + const SizedBox(height: 6), + Text( + 'Capture wins and signals from everyday interactions.', + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 16), + FrostedCard( + child: Row( + children: [ + Expanded( + child: TextField( + decoration: InputDecoration( + hintText: + 'Quick capture: what happened, how it felt, what to repeat...', + hintStyle: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: AppTheme.textSecondary), + border: InputBorder.none, + ), + ), + ), + FilledButton.icon( + onPressed: () {}, + icon: const Icon(Icons.add_rounded), + label: const Text('Add Capture'), + ), + ], + ), + ), + const SizedBox(height: 16), + Expanded( + child: ListView.separated( + itemCount: moments.length, + separatorBuilder: (_, _) => const SizedBox(height: 12), + itemBuilder: (BuildContext context, int index) { + final RelationshipMoment moment = moments[index]; + final PersonProfile? person = peopleById[moment.personId]; + return FrostedCard( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 12, + height: 12, + margin: const EdgeInsets.only(top: 6), + decoration: const BoxDecoration( + color: Color(0xFF1AB6C8), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + moment.title, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text( + '${person?.name ?? 'Unknown'} • ${moment.type.toUpperCase()}', + style: Theme.of(context).textTheme.labelLarge + ?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 8), + Text( + moment.summary, + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ), + ), + const SizedBox(width: 12), + Text( + '${moment.at.month}/${moment.at.day}', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/people/people_view.dart b/lib/features/people/people_view.dart new file mode 100644 index 0000000..f209fc1 --- /dev/null +++ b/lib/features/people/people_view.dart @@ -0,0 +1,273 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/core/config/app_theme.dart'; +import 'package:relationship_saver/features/local/local_models.dart'; +import 'package:relationship_saver/features/local/local_repository.dart'; +import 'package:relationship_saver/features/shared/frosted_card.dart'; + +class SelectedPersonIdNotifier extends Notifier { + @override + String? build() => null; + + void select(String id) { + state = id; + } +} + +final NotifierProvider +selectedPersonIdProvider = NotifierProvider( + SelectedPersonIdNotifier.new, +); + +class PeopleView extends ConsumerWidget { + const PeopleView({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final List people = ref + .watch(localRepositoryProvider) + .people(); + final String selectedId = + ref.watch(selectedPersonIdProvider) ?? people.first.id; + final PersonProfile selected = people.firstWhere( + (PersonProfile person) => person.id == selectedId, + orElse: () => people.first, + ); + + return Padding( + padding: const EdgeInsets.fromLTRB(28, 24, 28, 28), + child: Row( + children: [ + Expanded( + flex: 5, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'People', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 6), + Text( + 'Track what matters to each relationship and follow through.', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 20), + Expanded( + child: ListView.separated( + itemCount: people.length, + separatorBuilder: (_, _) => const SizedBox(height: 12), + itemBuilder: (BuildContext context, int index) { + final PersonProfile person = people[index]; + final bool isSelected = person.id == selected.id; + return InkWell( + onTap: () { + ref + .read(selectedPersonIdProvider.notifier) + .select(person.id); + }, + borderRadius: BorderRadius.circular(20), + child: FrostedCard( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + _AvatarSeed(name: person.name), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + person.name, + style: Theme.of( + context, + ).textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text( + person.relationship, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + _ScorePill(score: person.affinityScore), + if (isSelected) + const Padding( + padding: EdgeInsets.only(left: 10), + child: Icon( + Icons.check_circle, + color: Color(0xFF1AB6C8), + ), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ), + const SizedBox(width: 18), + Expanded( + flex: 6, + child: FrostedCard(child: _PersonDetail(person: selected)), + ), + ], + ), + ); + } +} + +class _PersonDetail extends StatelessWidget { + const _PersonDetail({required this.person}); + + final PersonProfile person; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _AvatarSeed(name: person.name, size: 64), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + person.name, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 4), + Text( + person.relationship, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + _ScorePill(score: person.affinityScore), + ], + ), + const SizedBox(height: 22), + Text('Next moment', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + _DetailTag( + label: + '${person.nextMoment.year}-${person.nextMoment.month.toString().padLeft(2, '0')}-${person.nextMoment.day.toString().padLeft(2, '0')} ${person.nextMoment.hour.toString().padLeft(2, '0')}:${person.nextMoment.minute.toString().padLeft(2, '0')}', + ), + const SizedBox(height: 18), + Text('Preferences', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: person.tags + .map((String tag) => _DetailTag(label: tag)) + .toList(), + ), + const SizedBox(height: 18), + Text('Notes', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Text( + person.notes, + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), + ), + ], + ); + } +} + +class _DetailTag extends StatelessWidget { + const _DetailTag({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xFFF2F8FA), + borderRadius: BorderRadius.circular(99), + ), + child: Text(label, style: Theme.of(context).textTheme.bodyMedium), + ); + } +} + +class _AvatarSeed extends StatelessWidget { + const _AvatarSeed({required this.name, this.size = 48}); + + final String name; + final double size; + + @override + Widget build(BuildContext context) { + final String initials = name + .split(' ') + .where((String part) => part.isNotEmpty) + .take(2) + .map((String part) => part[0].toUpperCase()) + .join(); + + return Container( + width: size, + height: size, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(size / 2), + gradient: const LinearGradient( + colors: [Color(0xFF1AB6C8), Color(0xFF2274E0)], + ), + ), + alignment: Alignment.center, + child: Text( + initials, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +class _ScorePill extends StatelessWidget { + const _ScorePill({required this.score}); + + final int score; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xFFEEF7F3), + borderRadius: BorderRadius.circular(99), + ), + child: Text( + '$score%', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: const Color(0xFF1D9C66), + fontWeight: FontWeight.w700, + ), + ), + ); + } +} diff --git a/lib/features/settings/settings_view.dart b/lib/features/settings/settings_view.dart new file mode 100644 index 0000000..bc2668a --- /dev/null +++ b/lib/features/settings/settings_view.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:relationship_saver/core/config/app_config.dart'; +import 'package:relationship_saver/core/config/app_theme.dart'; +import 'package:relationship_saver/features/shared/frosted_card.dart'; + +class SettingsView extends StatelessWidget { + const SettingsView({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(28, 24, 28, 28), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Settings', style: Theme.of(context).textTheme.headlineMedium), + const SizedBox(height: 6), + Text( + 'Environment and integration configuration for this build.', + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 16), + FrostedCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SettingRow( + label: 'Gateway mode', + value: AppConfig.useFakeBackend + ? 'Fake (offline-safe)' + : 'REST', + ), + const SizedBox(height: 12), + _SettingRow(label: 'Base URL', value: AppConfig.backendBaseUrl), + const SizedBox(height: 12), + const _SettingRow( + label: 'Realtime', + value: 'Planned later (SSE/WebSocket)', + ), + ], + ), + ), + const SizedBox(height: 16), + FrostedCard( + child: Text( + 'Run options\n• Fake mode: flutter run --dart-define=USE_FAKE_BACKEND=true\n• REST mode: flutter run --dart-define=USE_FAKE_BACKEND=false --dart-define=BACKEND_BASE_URL=https://your-api', + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + ], + ), + ); + } +} + +class _SettingRow extends StatelessWidget { + const _SettingRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 140, + child: Text( + label, + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(color: AppTheme.textSecondary), + ), + ), + Expanded( + child: Text(value, style: Theme.of(context).textTheme.bodyLarge), + ), + ], + ); + } +} diff --git a/lib/features/shared/frosted_card.dart b/lib/features/shared/frosted_card.dart new file mode 100644 index 0000000..21b3bdd --- /dev/null +++ b/lib/features/shared/frosted_card.dart @@ -0,0 +1,40 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +class FrostedCard extends StatelessWidget { + const FrostedCard({ + required this.child, + super.key, + this.padding = const EdgeInsets.all(20), + }); + + final Widget child; + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(24), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), + child: Container( + padding: padding, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.72), + borderRadius: BorderRadius.circular(24), + border: Border.all(color: Colors.white.withValues(alpha: 0.6)), + boxShadow: const [ + BoxShadow( + color: Color(0x14000000), + blurRadius: 24, + offset: Offset(0, 12), + ), + ], + ), + child: child, + ), + ), + ); + } +} diff --git a/lib/features/signals/signals_controller.dart b/lib/features/signals/signals_controller.dart new file mode 100644 index 0000000..a56467e --- /dev/null +++ b/lib/features/signals/signals_controller.dart @@ -0,0 +1,76 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/features/local/local_models.dart'; +import 'package:relationship_saver/features/local/local_repository.dart'; +import 'package:relationship_saver/integrations/backend/backend_gateway_provider.dart'; +import 'package:relationship_saver/integrations/backend/models/backend_models.dart'; + +class SignalsController extends AsyncNotifier { + @override + Future build() async { + return _load(); + } + + Future refresh() async { + state = const AsyncLoading(); + try { + final SignalsFeed feed = await _load(); + state = AsyncData(feed); + } catch (error, stackTrace) { + state = AsyncError(error, stackTrace); + } + } + + Future acknowledge(String id, SignalAction action) async { + final SignalsFeed? current = state.asData?.value; + if (current == null) { + return; + } + + await ref + .read(backendGatewayProvider) + .acknowledgeSignal(signalId: id, action: action); + + final List updated = current.items + .where((SignalItem item) => item.id != id) + .toList(growable: false); + state = AsyncData(current.copyWith(items: updated)); + } + + Future _load() async { + try { + return await ref.read(backendGatewayProvider).getSignalsFeed(limit: 20); + } catch (_) { + final List people = ref + .read(localRepositoryProvider) + .people(); + final DateTime now = DateTime.now().toUtc(); + return SignalsFeed( + cursor: 'fallback-signals', + items: [ + SignalItem( + id: 'fallback-1', + type: 'recommendation', + title: 'Check in with ${people.first.name} tonight', + description: 'A short voice memo can keep momentum.', + personId: people.first.id, + createdAt: now, + ), + SignalItem( + id: 'fallback-2', + type: 'trend', + title: 'Gift idea: practical + personal is trending', + description: 'Combine utility with one sentimental detail.', + personId: people.last.id, + createdAt: now.subtract(const Duration(hours: 2)), + ), + ], + ); + } + } +} + +final AsyncNotifierProvider +signalsControllerProvider = + AsyncNotifierProvider( + SignalsController.new, + ); diff --git a/lib/features/signals/signals_view.dart b/lib/features/signals/signals_view.dart new file mode 100644 index 0000000..695b80b --- /dev/null +++ b/lib/features/signals/signals_view.dart @@ -0,0 +1,158 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/core/config/app_theme.dart'; +import 'package:relationship_saver/features/shared/frosted_card.dart'; +import 'package:relationship_saver/features/signals/signals_controller.dart'; +import 'package:relationship_saver/integrations/backend/models/backend_models.dart'; + +class SignalsView extends ConsumerWidget { + const SignalsView({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final AsyncValue signals = ref.watch( + signalsControllerProvider, + ); + + return Padding( + padding: const EdgeInsets.fromLTRB(28, 24, 28, 28), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Signals', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 6), + Text( + 'Backend-driven recommendations and trends you can act on.', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + IconButton.filledTonal( + onPressed: () { + ref.read(signalsControllerProvider.notifier).refresh(); + }, + icon: const Icon(Icons.refresh_rounded), + tooltip: 'Refresh feed', + ), + ], + ), + const SizedBox(height: 16), + Expanded( + child: signals.when( + data: (SignalsFeed feed) { + if (feed.items.isEmpty) { + return const Center(child: Text('No signals right now.')); + } + + return ListView.separated( + itemCount: feed.items.length, + separatorBuilder: (_, _) => const SizedBox(height: 12), + itemBuilder: (BuildContext context, int index) { + final SignalItem item = feed.items[index]; + return FrostedCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: const Color(0xFFEAF8FB), + borderRadius: BorderRadius.circular(99), + ), + child: Text( + item.type.toUpperCase(), + style: Theme.of(context).textTheme.labelMedium + ?.copyWith( + color: AppTheme.primary, + fontWeight: FontWeight.w700, + ), + ), + ), + const Spacer(), + Text( + '${item.createdAt.month}/${item.createdAt.day}', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: AppTheme.textSecondary), + ), + ], + ), + const SizedBox(height: 12), + Text( + item.title, + style: Theme.of(context).textTheme.titleLarge, + ), + if (item.description case final String description) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + description, + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith(color: AppTheme.textSecondary), + ), + ), + const SizedBox(height: 14), + Row( + children: [ + OutlinedButton.icon( + onPressed: () { + ref + .read(signalsControllerProvider.notifier) + .acknowledge(item.id, SignalAction.saved); + }, + icon: const Icon(Icons.bookmark_add_outlined), + label: const Text('Save'), + ), + const SizedBox(width: 8), + TextButton.icon( + onPressed: () { + ref + .read(signalsControllerProvider.notifier) + .acknowledge( + item.id, + SignalAction.dismissed, + ); + }, + icon: const Icon(Icons.close_rounded), + label: const Text('Dismiss'), + ), + ], + ), + ], + ), + ); + }, + ); + }, + error: (Object error, StackTrace stackTrace) { + return Center( + child: Text( + 'Unable to load signals. Try refresh.', + style: Theme.of(context).textTheme.bodyLarge, + ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/sync/sync_view.dart b/lib/features/sync/sync_view.dart new file mode 100644 index 0000000..8883beb --- /dev/null +++ b/lib/features/sync/sync_view.dart @@ -0,0 +1,153 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/core/config/app_theme.dart'; +import 'package:relationship_saver/features/shared/frosted_card.dart'; +import 'package:relationship_saver/integrations/backend/backend_gateway_provider.dart'; +import 'package:relationship_saver/integrations/backend/models/backend_models.dart'; +import 'package:uuid/uuid.dart'; + +class SyncView extends ConsumerStatefulWidget { + const SyncView({super.key}); + + @override + ConsumerState createState() => _SyncViewState(); +} + +class _SyncViewState extends ConsumerState { + static const Uuid _uuid = Uuid(); + + bool _busy = false; + String _status = 'Ready. Local-first mode keeps app usable without sync.'; + String? _cursor; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(28, 24, 28, 28), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Sync', style: Theme.of(context).textTheme.headlineMedium), + const SizedBox(height: 6), + Text( + 'Push local changes and pull remote updates when connectivity is available.', + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 16), + FrostedCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + FilledButton.icon( + onPressed: _busy ? null : _pushSample, + icon: const Icon(Icons.upload_rounded), + label: const Text('Push Sample Changes'), + ), + OutlinedButton.icon( + onPressed: _busy ? null : _pullChanges, + icon: const Icon(Icons.download_rounded), + label: const Text('Pull Changes'), + ), + ], + ), + const SizedBox(height: 16), + Text( + 'Cursor: ${_cursor ?? 'not set'}', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 8), + Text( + _status, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + FrostedCard( + child: Text( + 'Sync policy\n• Core usage never blocks on backend\n• Retries only for transient failures\n• Non-idempotent requests require idempotency key', + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + ], + ), + ); + } + + Future _pullChanges() async { + setState(() { + _busy = true; + _status = 'Pulling changes...'; + }); + + try { + final SyncPullResult result = await ref + .read(backendGatewayProvider) + .pullChanges(cursor: _cursor); + + setState(() { + _cursor = result.cursor; + _status = 'Pulled ${result.changes.length} changes successfully.'; + }); + } catch (error) { + setState(() { + _status = 'Pull failed. Staying offline-safe. ($error)'; + }); + } finally { + setState(() { + _busy = false; + }); + } + } + + Future _pushSample() async { + setState(() { + _busy = true; + _status = 'Pushing local sample changes...'; + }); + + final List changes = [ + ChangeEnvelope( + schemaVersion: 1, + entityType: 'capture', + entityId: 'capture-${_uuid.v4()}', + op: ChangeOperation.upsert, + modifiedAt: DateTime.now().toUtc(), + clientMutationId: _uuid.v4(), + payload: const { + 'summary': 'Shared a thoughtful check-in after work.', + 'sentiment': 'positive', + }, + ), + ]; + + try { + final SyncPushResult result = await ref + .read(backendGatewayProvider) + .pushChanges(changes: changes, cursor: _cursor); + + setState(() { + _cursor = result.cursor; + _status = + 'Push complete. accepted=${result.accepted.length}, rejected=${result.rejected.length}'; + }); + } catch (error) { + setState(() { + _status = 'Push failed. Local data remains safe. ($error)'; + }); + } finally { + setState(() { + _busy = false; + }); + } + } +} diff --git a/lib/main.dart b/lib/main.dart index faea750..c6e0e68 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:relationship_saver/core/config/app_config.dart'; import 'package:relationship_saver/core/config/app_theme.dart'; +import 'package:relationship_saver/features/home/app_shell.dart'; void main() { runApp(const ProviderScope(child: RelationshipSaverApp())); @@ -16,40 +16,7 @@ class RelationshipSaverApp extends StatelessWidget { title: 'Relationship Saver', debugShowCheckedModeBanner: false, theme: AppTheme.light(), - home: const _LandingScreen(), - ); - } -} - -class _LandingScreen extends StatelessWidget { - const _LandingScreen(); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Relationship Saver')), - body: Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Backend gateway scaffold is ready.', - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: 12), - Text( - 'Mode: ${AppConfig.useFakeBackend ? 'Fake gateway' : 'REST gateway'}', - style: Theme.of(context).textTheme.bodyLarge, - ), - const SizedBox(height: 8), - Text( - 'Base URL: ${AppConfig.backendBaseUrl}', - style: Theme.of(context).textTheme.bodyMedium, - ), - ], - ), - ), + home: const AppShell(), ); } } diff --git a/pubspec.yaml b/pubspec.yaml index d001ad3..df84d89 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -68,6 +68,17 @@ flutter: # the material Icons class. uses-material-design: true + fonts: + - family: WorkSans + fonts: + - asset: assets/fonts/WorkSans-Regular.ttf + - asset: assets/fonts/WorkSans-Medium.ttf + weight: 500 + - asset: assets/fonts/WorkSans-SemiBold.ttf + weight: 600 + - asset: assets/fonts/WorkSans-Bold.ttf + weight: 700 + # To add assets to your application, add an assets section, like this: # assets: # - images/a_dot_burr.jpeg diff --git a/test/widget_test.dart b/test/widget_test.dart index dbb052b..4788b72 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,14 +1,13 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:relationship_saver/main.dart'; void main() { - testWidgets('renders app shell', (WidgetTester tester) async { - await tester.pumpWidget(const RelationshipSaverApp()); + testWidgets('renders dashboard flow', (WidgetTester tester) async { + await tester.pumpWidget(const ProviderScope(child: RelationshipSaverApp())); + await tester.pumpAndSettle(); - expect(find.text('Relationship Saver'), findsOneWidget); - expect( - find.textContaining('Backend gateway scaffold is ready.'), - findsOneWidget, - ); + expect(find.text('Today'), findsOneWidget); + expect(find.text('Active People'), findsOneWidget); }); }