feat: native family profiles screen with dossier switcher

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)
This commit is contained in:
Johan Jongsma 2026-03-23 12:33:37 -04:00
parent dd7a998661
commit add0071650
2 changed files with 370 additions and 4 deletions

View File

@ -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<String, dynamic> 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 = <String>[];
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<FamilyScreen> createState() => _FamilyScreenState();
}
class _FamilyScreenState extends State<FamilyScreen> {
final _api = InouApi();
List<DossierProfile>? _profiles;
String? _error;
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _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<String, dynamic>;
final list = (data['dossiers'] as List<dynamic>? ?? [])
.map((e) => DossierProfile.fromJson(e as Map<String, dynamic>))
.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')),
],
),
),
);
}
}

View File

@ -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<MainScaffold> {
int _currentIndex = 0;
final GlobalKey<WebViewScreenState> _webViewKey = GlobalKey<WebViewScreenState>();
/// 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<MainScaffold> {
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),