Implement multi-screen app UI flow and navigation
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<DashboardTask> tasks = repository.tasks();
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(28, 24, 28, 36),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
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: <Widget>[
|
||||
_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: <Widget>[
|
||||
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: <Widget>[
|
||||
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: <Widget>[
|
||||
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: <Widget>[
|
||||
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<String> weekdays = <String>[
|
||||
'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';
|
||||
}
|
||||
@@ -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<AppShell> createState() => _AppShellState();
|
||||
}
|
||||
|
||||
class _AppShellState extends ConsumerState<AppShell> {
|
||||
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>[
|
||||
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: <Widget>[
|
||||
_Sidebar(
|
||||
index: _index,
|
||||
destinations: _destinations,
|
||||
onChange: (int value) {
|
||||
setState(() {
|
||||
_index = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: KeyedSubtree(
|
||||
key: ValueKey<int>(_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<int> 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: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
gradient: const LinearGradient(
|
||||
colors: <Color>[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: <Widget>[
|
||||
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<int> onChange;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
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;
|
||||
}
|
||||
@@ -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<String> 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;
|
||||
}
|
||||
@@ -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<PersonProfile> people() {
|
||||
return <PersonProfile>[
|
||||
PersonProfile(
|
||||
id: 'p-ava',
|
||||
name: 'Ava Hart',
|
||||
relationship: 'Partner',
|
||||
affinityScore: 92,
|
||||
nextMoment: DateTime(2026, 2, 16, 19, 30),
|
||||
tags: <String>['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: <String>['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: <String>['plants', 'retro music'],
|
||||
notes: 'Birthday prep should start this week.',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<RelationshipMoment> moments() {
|
||||
return <RelationshipMoment>[
|
||||
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<DashboardTask> tasks() {
|
||||
return <DashboardTask>[
|
||||
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<LocalRepository> localRepositoryProvider =
|
||||
Provider<LocalRepository>((Ref ref) {
|
||||
return const LocalRepository();
|
||||
});
|
||||
@@ -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<RelationshipMoment> moments = repository.moments();
|
||||
final Map<String, PersonProfile> peopleById = <String, PersonProfile>{
|
||||
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: <Widget>[
|
||||
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: <Widget>[
|
||||
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: <Widget>[
|
||||
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: <Widget>[
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<String?> {
|
||||
@override
|
||||
String? build() => null;
|
||||
|
||||
void select(String id) {
|
||||
state = id;
|
||||
}
|
||||
}
|
||||
|
||||
final NotifierProvider<SelectedPersonIdNotifier, String?>
|
||||
selectedPersonIdProvider = NotifierProvider<SelectedPersonIdNotifier, String?>(
|
||||
SelectedPersonIdNotifier.new,
|
||||
);
|
||||
|
||||
class PeopleView extends ConsumerWidget {
|
||||
const PeopleView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final List<PersonProfile> 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: <Widget>[
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
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: <Widget>[
|
||||
_AvatarSeed(name: person.name),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
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: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
_AvatarSeed(name: person.name, size: 64),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
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>[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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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: <Widget>[
|
||||
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: <Widget>[
|
||||
_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: <Widget>[
|
||||
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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>[
|
||||
BoxShadow(
|
||||
color: Color(0x14000000),
|
||||
blurRadius: 24,
|
||||
offset: Offset(0, 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<SignalsFeed> {
|
||||
@override
|
||||
Future<SignalsFeed> build() async {
|
||||
return _load();
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncLoading<SignalsFeed>();
|
||||
try {
|
||||
final SignalsFeed feed = await _load();
|
||||
state = AsyncData<SignalsFeed>(feed);
|
||||
} catch (error, stackTrace) {
|
||||
state = AsyncError<SignalsFeed>(error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> 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<SignalItem> updated = current.items
|
||||
.where((SignalItem item) => item.id != id)
|
||||
.toList(growable: false);
|
||||
state = AsyncData<SignalsFeed>(current.copyWith(items: updated));
|
||||
}
|
||||
|
||||
Future<SignalsFeed> _load() async {
|
||||
try {
|
||||
return await ref.read(backendGatewayProvider).getSignalsFeed(limit: 20);
|
||||
} catch (_) {
|
||||
final List<PersonProfile> people = ref
|
||||
.read(localRepositoryProvider)
|
||||
.people();
|
||||
final DateTime now = DateTime.now().toUtc();
|
||||
return SignalsFeed(
|
||||
cursor: 'fallback-signals',
|
||||
items: <SignalItem>[
|
||||
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<SignalsController, SignalsFeed>
|
||||
signalsControllerProvider =
|
||||
AsyncNotifierProvider<SignalsController, SignalsFeed>(
|
||||
SignalsController.new,
|
||||
);
|
||||
@@ -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<SignalsFeed> signals = ref.watch(
|
||||
signalsControllerProvider,
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(28, 24, 28, 28),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
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: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
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: <Widget>[
|
||||
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()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<SyncView> createState() => _SyncViewState();
|
||||
}
|
||||
|
||||
class _SyncViewState extends ConsumerState<SyncView> {
|
||||
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: <Widget>[
|
||||
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: <Widget>[
|
||||
Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: <Widget>[
|
||||
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<void> _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<void> _pushSample() async {
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_status = 'Pushing local sample changes...';
|
||||
});
|
||||
|
||||
final List<ChangeEnvelope> changes = <ChangeEnvelope>[
|
||||
ChangeEnvelope(
|
||||
schemaVersion: 1,
|
||||
entityType: 'capture',
|
||||
entityId: 'capture-${_uuid.v4()}',
|
||||
op: ChangeOperation.upsert,
|
||||
modifiedAt: DateTime.now().toUtc(),
|
||||
clientMutationId: _uuid.v4(),
|
||||
payload: const <String, dynamic>{
|
||||
'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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-35
@@ -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: <Widget>[
|
||||
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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user