From add007165042552b65c11e8444cdf3b4c877d488 Mon Sep 17 00:00:00 2001 From: Johan Jongsma Date: Mon, 23 Mar 2026 12:33:37 -0400 Subject: [PATCH] feat: native family profiles screen with dossier switcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a dedicated Family tab (tab index 2) to the bottom nav bar showing all accessible dossiers as native profile cards. Tapping a card navigates the home WebView directly to that dossier. FamilyScreen: - Fetches /api/v1/dashboard on load (pull-to-refresh) - Profile cards: avatar with initials + color-coded background, name, relation + age + sex subtitle, chevron - 'Add family member' CTA card at the bottom (opens /dossier/add in WebView) - Error state with retry button - Loading + empty state handling Navigation: - Tapping a profile sets tab to Home and loads the dossier URL in the existing WebView via loadRequest() โ€” no new WebView instance - Settings moved to tab index 3 (was 2) API: - Uses enriched /api/v1/dashboard fields: initials, color, age, dob, sex, is_self (added in companion server commit) --- lib/features/family/family_screen.dart | 349 +++++++++++++++++++++++++ lib/main.dart | 25 +- 2 files changed, 370 insertions(+), 4 deletions(-) create mode 100644 lib/features/family/family_screen.dart diff --git a/lib/features/family/family_screen.dart b/lib/features/family/family_screen.dart new file mode 100644 index 0000000..0f21d4c --- /dev/null +++ b/lib/features/family/family_screen.dart @@ -0,0 +1,349 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import '../../core/config.dart'; +import '../../core/theme.dart'; +import '../../services/inou_api.dart'; + +/// A dossier entry from /api/v1/dashboard +class DossierProfile { + final String guid; + final String name; + final String relation; + final bool canAdd; + final String initials; + final String color; + final String age; + final String dob; + final int sex; + final bool isSelf; + + const DossierProfile({ + required this.guid, + required this.name, + required this.relation, + required this.canAdd, + required this.initials, + required this.color, + required this.age, + required this.dob, + required this.sex, + required this.isSelf, + }); + + factory DossierProfile.fromJson(Map j) => DossierProfile( + guid: j['guid'] as String? ?? '', + name: j['name'] as String? ?? '', + relation: j['relation'] as String? ?? 'other', + canAdd: j['can_add'] as bool? ?? false, + initials: j['initials'] as String? ?? '?', + color: j['color'] as String? ?? '#7E8FC2', + age: j['age'] as String? ?? '', + dob: j['dob'] as String? ?? '', + sex: j['sex'] as int? ?? 0, + isSelf: j['is_self'] as bool? ?? false, + ); + + String get displayName => isSelf && name.isEmpty ? 'Me' : name; + + String get subtitle { + final parts = []; + if (!isSelf && relation.isNotEmpty && relation != 'other') { + parts.add(_capitalise(relation)); + } + if (age.isNotEmpty) parts.add(age); + if (sex == 1) parts.add('M'); + if (sex == 2) parts.add('F'); + return parts.join(' ยท '); + } + + static String _capitalise(String s) => + s.isEmpty ? s : s[0].toUpperCase() + s.substring(1); + + Color get avatarColor { + try { + final hex = color.replaceFirst('#', ''); + return Color(int.parse('FF$hex', radix: 16)); + } catch (_) { + return AppTheme.primaryColor; + } + } +} + +/// Native family profiles screen. +/// Shows all accessible dossiers as cards; tapping opens the dossier in the +/// portal WebView. Also shows an "Add family member" CTA at the bottom. +class FamilyScreen extends StatefulWidget { + /// Called when a dossier is selected โ€” navigate the WebView to this URL. + final void Function(String url)? onOpenDossier; + + const FamilyScreen({super.key, this.onOpenDossier}); + + @override + State createState() => _FamilyScreenState(); +} + +class _FamilyScreenState extends State { + final _api = InouApi(); + List? _profiles; + String? _error; + bool _loading = true; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { _loading = true; _error = null; }); + try { + final resp = await _api.get('/api/v1/dashboard'); + if (resp.statusCode == 200) { + final data = jsonDecode(resp.body) as Map; + final list = (data['dossiers'] as List? ?? []) + .map((e) => DossierProfile.fromJson(e as Map)) + .toList(); + setState(() { _profiles = list; _loading = false; }); + } else { + setState(() { _error = 'Could not load profiles (${resp.statusCode})'; _loading = false; }); + } + } catch (e) { + setState(() { _error = 'Network error: $e'; _loading = false; }); + } + } + + void _openDossier(DossierProfile p) { + // webAppUrl is e.g. https://inou.com/app โ€” strip trailing path to get origin + final origin = AppConfig.webAppUrl.replaceFirst(RegExp(r'/app.*$'), ''); + final url = '$origin/dossier/${p.guid}'; + if (widget.onOpenDossier != null) { + widget.onOpenDossier!(url); + } + } + + void _openAddDossier() { + final origin = AppConfig.webAppUrl.replaceFirst(RegExp(r'/app.*$'), ''); + final url = '$origin/dossier/add'; + if (widget.onOpenDossier != null) { + widget.onOpenDossier!(url); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.backgroundColor, + body: SafeArea( + child: RefreshIndicator( + onRefresh: _load, + color: AppTheme.primaryColor, + child: CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 8), + child: Text( + 'Family', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: AppTheme.textColor, + ), + ), + ), + ), + if (_loading) + const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ) + else if (_error != null) + SliverFillRemaining( + child: _ErrorView(message: _error!, onRetry: _load), + ) + else ...[ + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, i) { + final p = _profiles![i]; + return _ProfileCard( + profile: p, + onTap: () => _openDossier(p), + ); + }, + childCount: _profiles!.length, + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), + child: _AddFamilyMemberCard(onTap: _openAddDossier), + ), + ), + ], + ], + ), + ), + ), + ); + } +} + +class _ProfileCard extends StatelessWidget { + final DossierProfile profile; + final VoidCallback onTap; + + const _ProfileCard({required this.profile, required this.onTap}); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.only(bottom: 12), + color: AppTheme.surfaceColor, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + // Avatar + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: profile.avatarColor, + borderRadius: BorderRadius.circular(14), + ), + child: Center( + child: Text( + profile.initials, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(width: 14), + // Name + meta + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + profile.displayName, + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w600, + color: AppTheme.textColor, + ), + ), + if (profile.subtitle.isNotEmpty) ...[ + const SizedBox(height: 3), + Text( + profile.subtitle, + style: TextStyle( + fontSize: 13, + color: AppTheme.textColor.withOpacity(0.6), + ), + ), + ], + ], + ), + ), + // Chevron + Icon( + Icons.chevron_right, + color: AppTheme.textColor.withOpacity(0.3), + ), + ], + ), + ), + ), + ); + } +} + +class _AddFamilyMemberCard extends StatelessWidget { + final VoidCallback onTap; + + const _AddFamilyMemberCard({required this.onTap}); + + @override + Widget build(BuildContext context) { + return Card( + color: AppTheme.surfaceColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: AppTheme.primaryColor.withOpacity(0.3), + width: 1.5, + ), + ), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: AppTheme.primaryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(14), + ), + child: Icon( + Icons.person_add_outlined, + color: AppTheme.primaryColor, + size: 26, + ), + ), + const SizedBox(width: 14), + Text( + 'Add family member', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: AppTheme.primaryColor, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _ErrorView extends StatelessWidget { + final String message; + final VoidCallback onRetry; + + const _ErrorView({required this.message, required this.onRetry}); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, size: 48, color: Colors.red.shade400), + const SizedBox(height: 16), + Text(message, textAlign: TextAlign.center), + const SizedBox(height: 24), + ElevatedButton(onPressed: onRetry, child: const Text('Retry')), + ], + ), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 731f853..f48f8ca 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,6 +6,7 @@ import 'core/auth_gate.dart'; import 'features/webview/webview_screen.dart'; import 'features/input/input_screen.dart'; import 'features/settings/settings_screen.dart'; +import 'features/family/family_screen.dart'; import 'features/auth/login_screen.dart'; import 'services/auth_service.dart'; @@ -180,16 +181,27 @@ class MainScaffold extends StatefulWidget { class MainScaffoldState extends State { int _currentIndex = 0; + final GlobalKey _webViewKey = GlobalKey(); + + /// Navigate the home WebView to a URL (called by FamilyScreen when a dossier is tapped) + void _openInWebView(String url) { + setState(() { _currentIndex = 0; }); + // Give IndexedStack a frame to show the WebView before navigating + WidgetsBinding.instance.addPostFrameCallback((_) { + _webViewKey.currentState?.webViewState?.controller.loadRequest(Uri.parse(url)); + }); + } @override Widget build(BuildContext context) { return Scaffold( body: IndexedStack( index: _currentIndex, - children: const [ - WebViewScreen(), - InputScreen(), - SettingsScreen(), + children: [ + WebViewScreen(key: _webViewKey), + const InputScreen(), + FamilyScreen(onOpenDossier: _openInWebView), + const SettingsScreen(), ], ), bottomNavigationBar: NavigationBar( @@ -210,6 +222,11 @@ class MainScaffoldState extends State { selectedIcon: Icon(Icons.edit), label: 'Input', ), + NavigationDestination( + icon: Icon(Icons.people_outline), + selectedIcon: Icon(Icons.people), + label: 'Family', + ), NavigationDestination( icon: Icon(Icons.settings_outlined), selectedIcon: Icon(Icons.settings),