Add new friends system, and sharing legs support
All checks were successful
Release / meta (push) Successful in 9s
Release / linux-build (push) Successful in 6m37s
Release / web-build (push) Successful in 5m29s
Release / android-build (push) Successful in 15m58s
Release / release-master (push) Successful in 20s
Release / release-dev (push) Successful in 26s
All checks were successful
Release / meta (push) Successful in 9s
Release / linux-build (push) Successful in 6m37s
Release / web-build (push) Successful in 5m29s
Release / android-build (push) Successful in 15m58s
Release / release-master (push) Successful in 20s
Release / release-dev (push) Successful in 26s
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import java.io.File
|
||||||
import java.util.Properties
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
@@ -22,8 +23,21 @@ val releaseKeyAlias = System.getenv("ANDROID_KEY_ALIAS")
|
|||||||
val releaseKeyPassword = System.getenv("ANDROID_KEY_PASSWORD")
|
val releaseKeyPassword = System.getenv("ANDROID_KEY_PASSWORD")
|
||||||
?: keystoreProperties.getProperty("keyPassword")
|
?: keystoreProperties.getProperty("keyPassword")
|
||||||
|
|
||||||
|
val releaseStoreFilePath: File? = releaseStoreFile?.let { path ->
|
||||||
|
val candidate = File(path)
|
||||||
|
if (candidate.isAbsolute) return@let candidate
|
||||||
|
|
||||||
|
val candidates = listOfNotNull(
|
||||||
|
rootProject.file(path),
|
||||||
|
rootProject.projectDir.parentFile?.let { File(it, path) },
|
||||||
|
project.file(path),
|
||||||
|
)
|
||||||
|
|
||||||
|
candidates.firstOrNull { it.exists() }
|
||||||
|
}
|
||||||
|
|
||||||
val hasReleaseKeystore = listOf(
|
val hasReleaseKeystore = listOf(
|
||||||
releaseStoreFile,
|
releaseStoreFilePath?.path,
|
||||||
releaseStorePassword,
|
releaseStorePassword,
|
||||||
releaseKeyAlias,
|
releaseKeyAlias,
|
||||||
releaseKeyPassword
|
releaseKeyPassword
|
||||||
@@ -48,8 +62,8 @@ android {
|
|||||||
applicationId = "com.petegregoryy.mileograph_flutter"
|
applicationId = "com.petegregoryy.mileograph_flutter"
|
||||||
// You can update the following values to match your application needs.
|
// You can update the following values to match your application needs.
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = 24
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = 36
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
}
|
}
|
||||||
@@ -57,7 +71,7 @@ android {
|
|||||||
signingConfigs {
|
signingConfigs {
|
||||||
if (hasReleaseKeystore) {
|
if (hasReleaseKeystore) {
|
||||||
create("release") {
|
create("release") {
|
||||||
storeFile = file(releaseStoreFile!!)
|
storeFile = releaseStoreFilePath
|
||||||
storePassword = releaseStorePassword
|
storePassword = releaseStorePassword
|
||||||
keyAlias = releaseKeyAlias!!
|
keyAlias = releaseKeyAlias!!
|
||||||
keyPassword = releaseKeyPassword
|
keyPassword = releaseKeyPassword
|
||||||
|
|||||||
@@ -27,15 +27,20 @@ class _LegCardState extends State<LegCard> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final leg = widget.leg;
|
final leg = widget.leg;
|
||||||
|
final isShared = leg.legShareId != null && leg.legShareId!.isNotEmpty;
|
||||||
final distanceUnits = context.watch<DistanceUnitService>();
|
final distanceUnits = context.watch<DistanceUnitService>();
|
||||||
final routeSegments = _parseRouteSegments(leg.route);
|
final routeSegments = _parseRouteSegments(leg.route);
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
return Card(
|
return Card(
|
||||||
child: ExpansionTile(
|
clipBehavior: Clip.antiAlias,
|
||||||
onExpansionChanged: (v) => setState(() => _expanded = v),
|
elevation: 1,
|
||||||
tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
child: Theme(
|
||||||
leading: const Icon(Icons.train),
|
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
|
||||||
title: LayoutBuilder(
|
child: ExpansionTile(
|
||||||
|
onExpansionChanged: (v) => setState(() => _expanded = v),
|
||||||
|
tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
|
leading: const Icon(Icons.train),
|
||||||
|
title: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final isWide = constraints.maxWidth > 520;
|
final isWide = constraints.maxWidth > 520;
|
||||||
final beginTimeWidget = _timeWithDelay(
|
final beginTimeWidget = _timeWithDelay(
|
||||||
@@ -108,7 +113,7 @@ class _LegCardState extends State<LegCard> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
subtitle: LayoutBuilder(
|
subtitle: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final isWide = constraints.maxWidth > 520;
|
final isWide = constraints.maxWidth > 520;
|
||||||
final tractionWrap = !_expanded && leg.locos.isNotEmpty
|
final tractionWrap = !_expanded && leg.locos.isNotEmpty
|
||||||
@@ -168,7 +173,7 @@ class _LegCardState extends State<LegCard> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
trailing: Row(
|
trailing: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Column(
|
Column(
|
||||||
@@ -193,6 +198,17 @@ class _LegCardState extends State<LegCard> {
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
if (isShared) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Tooltip(
|
||||||
|
message: 'Shared entry',
|
||||||
|
child: Icon(
|
||||||
|
Icons.share,
|
||||||
|
size: 18,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
if (widget.showEditButton) ...[
|
if (widget.showEditButton) ...[
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -217,44 +233,45 @@ class _LegCardState extends State<LegCard> {
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
if (leg.notes.isNotEmpty) ...[
|
|
||||||
Text('Notes', style: textTheme.titleSmall),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(leg.notes),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
],
|
|
||||||
if (leg.locos.isNotEmpty) ...[
|
|
||||||
Text('Locos', style: textTheme.titleSmall),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 8,
|
|
||||||
children: _buildLocoChips(context, leg),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
],
|
|
||||||
if (_hasTrainDetails(leg)) ...[
|
|
||||||
Text('Train', style: textTheme.titleSmall),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
..._buildTrainDetails(leg, textTheme),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
],
|
|
||||||
if (routeSegments.isNotEmpty) ...[
|
|
||||||
Text('Route', style: textTheme.titleSmall),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
_buildRouteList(routeSegments),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (leg.notes.isNotEmpty) ...[
|
||||||
|
Text('Notes', style: textTheme.titleSmall),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(leg.notes),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
if (leg.locos.isNotEmpty) ...[
|
||||||
|
Text('Locos', style: textTheme.titleSmall),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: _buildLocoChips(context, leg),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
if (_hasTrainDetails(leg)) ...[
|
||||||
|
Text('Train', style: textTheme.titleSmall),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
..._buildTrainDetails(leg, textTheme),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
if (routeSegments.isNotEmpty) ...[
|
||||||
|
Text('Route', style: textTheme.titleSmall),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_buildRouteList(routeSegments),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -294,6 +294,7 @@ class _RegisterPanelContentState extends State<RegisterPanelContent> {
|
|||||||
final _passwordController = TextEditingController();
|
final _passwordController = TextEditingController();
|
||||||
final _inviteController = TextEditingController();
|
final _inviteController = TextEditingController();
|
||||||
bool _registering = false;
|
bool _registering = false;
|
||||||
|
String? _usernameError;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@@ -306,10 +307,21 @@ class _RegisterPanelContentState extends State<RegisterPanelContent> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _register() async {
|
Future<void> _register() async {
|
||||||
|
final username = _usernameController.text.trim();
|
||||||
|
if (username.contains(' ') || username.contains('@')) {
|
||||||
|
setState(() {
|
||||||
|
_usernameError = 'Username cannot contain spaces or @';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
_usernameError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
setState(() => _registering = true);
|
setState(() => _registering = true);
|
||||||
try {
|
try {
|
||||||
await widget.authService.register(
|
await widget.authService.register(
|
||||||
username: _usernameController.text.trim(),
|
username: username,
|
||||||
email: _emailController.text.trim(),
|
email: _emailController.text.trim(),
|
||||||
fullName: _displayNameController.text.trim(),
|
fullName: _displayNameController.text.trim(),
|
||||||
password: _passwordController.text,
|
password: _passwordController.text,
|
||||||
@@ -363,7 +375,22 @@ class _RegisterPanelContentState extends State<RegisterPanelContent> {
|
|||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
labelText: "Username",
|
labelText: "Username",
|
||||||
),
|
),
|
||||||
|
onChanged: (_) {
|
||||||
|
if (_usernameError != null &&
|
||||||
|
!_usernameController.text.contains(' ') &&
|
||||||
|
!_usernameController.text.contains('@')) {
|
||||||
|
setState(() => _usernameError = null);
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
|
if (_usernameError != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4.0),
|
||||||
|
child: Text(
|
||||||
|
_usernameError!,
|
||||||
|
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _displayNameController,
|
controller: _displayNameController,
|
||||||
|
|||||||
705
lib/components/pages/badges.dart
Normal file
705
lib/components/pages/badges.dart
Normal file
@@ -0,0 +1,705 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:mileograph_flutter/objects/objects.dart';
|
||||||
|
import 'package:mileograph_flutter/services/data_service.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
class BadgesPage extends StatefulWidget {
|
||||||
|
const BadgesPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<BadgesPage> createState() => _BadgesPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BadgesPageState extends State<BadgesPage> {
|
||||||
|
bool _initialised = false;
|
||||||
|
final Map<String, bool> _groupExpanded = {};
|
||||||
|
bool _loadingAwards = false;
|
||||||
|
bool _loadingClassProgress = false;
|
||||||
|
bool _loadingLocoProgress = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
if (_initialised) return;
|
||||||
|
_initialised = true;
|
||||||
|
_refreshAwards();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refreshAwards() {
|
||||||
|
_loadingAwards = false;
|
||||||
|
_loadingClassProgress = false;
|
||||||
|
_loadingLocoProgress = false;
|
||||||
|
final data = context.read<DataService>();
|
||||||
|
return Future.wait([
|
||||||
|
data.fetchBadgeAwards(limit: 20, badgeCode: 'class_clearance'),
|
||||||
|
data.fetchClassClearanceProgress(),
|
||||||
|
data.fetchLocoClearanceProgress(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final data = context.watch<DataService>();
|
||||||
|
final awards = data.badgeAwards;
|
||||||
|
final loading = data.isBadgeAwardsLoading;
|
||||||
|
final classProgress = data.classClearanceProgress;
|
||||||
|
final classProgressLoading =
|
||||||
|
data.isClassClearanceProgressLoading || _loadingClassProgress;
|
||||||
|
final locoProgress = data.locoClearanceProgress;
|
||||||
|
final locoProgressLoading =
|
||||||
|
data.isLocoClearanceProgressLoading || _loadingLocoProgress;
|
||||||
|
final hasAnyData =
|
||||||
|
awards.isNotEmpty || classProgress.isNotEmpty || locoProgress.isNotEmpty;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Badges'),
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () {
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
if (navigator.canPop()) {
|
||||||
|
navigator.pop();
|
||||||
|
} else {
|
||||||
|
context.go('/');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: RefreshIndicator(
|
||||||
|
onRefresh: _refreshAwards,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
if ((loading || classProgressLoading || locoProgressLoading) &&
|
||||||
|
!hasAnyData)
|
||||||
|
const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 24.0),
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (!hasAnyData)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 12.0),
|
||||||
|
child: Text('No badges awarded yet.'),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
..._buildGroupedAwards(
|
||||||
|
context,
|
||||||
|
awards,
|
||||||
|
classProgress,
|
||||||
|
locoProgress,
|
||||||
|
classProgressLoading,
|
||||||
|
locoProgressLoading,
|
||||||
|
data.classClearanceHasMore,
|
||||||
|
data.locoClearanceHasMore,
|
||||||
|
data.badgeAwardsHasMore,
|
||||||
|
loading,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildGroupedAwards(
|
||||||
|
BuildContext context,
|
||||||
|
List<BadgeAward> awards,
|
||||||
|
List<ClassClearanceProgress> classProgress,
|
||||||
|
List<LocoClearanceProgress> locoProgress,
|
||||||
|
bool classProgressLoading,
|
||||||
|
bool locoProgressLoading,
|
||||||
|
bool classProgressHasMore,
|
||||||
|
bool locoProgressHasMore,
|
||||||
|
bool badgeAwardsHasMore,
|
||||||
|
bool badgeAwardsLoading,
|
||||||
|
) {
|
||||||
|
final grouped = _groupAwards(awards);
|
||||||
|
if ((classProgress.isNotEmpty || classProgressLoading) &&
|
||||||
|
!grouped.containsKey('class_clearance')) {
|
||||||
|
grouped['class_clearance'] = [];
|
||||||
|
}
|
||||||
|
if ((locoProgress.isNotEmpty || locoProgressLoading) &&
|
||||||
|
!grouped.containsKey('loco_clearance')) {
|
||||||
|
grouped['loco_clearance'] = [];
|
||||||
|
}
|
||||||
|
final codes = _orderedBadgeCodes(grouped.keys.toList());
|
||||||
|
|
||||||
|
return codes.map((code) {
|
||||||
|
final items = grouped[code]!;
|
||||||
|
final expanded = _groupExpanded[code] ?? true;
|
||||||
|
final title = _formatBadgeName(code);
|
||||||
|
final isClass = code == 'class_clearance';
|
||||||
|
final isLoco = code == 'loco_clearance';
|
||||||
|
final classItems = isClass ? classProgress : <ClassClearanceProgress>[];
|
||||||
|
final locoItems = isLoco ? locoProgress : <LocoClearanceProgress>[];
|
||||||
|
final awardCount = isLoco
|
||||||
|
? locoItems.where((item) => item.awardedTiers.isNotEmpty).length
|
||||||
|
: items.length;
|
||||||
|
final isLoadingSection = isClass
|
||||||
|
? (classProgressLoading || badgeAwardsLoading || _loadingAwards)
|
||||||
|
: (isLoco ? locoProgressLoading : false);
|
||||||
|
|
||||||
|
final children = <Widget>[];
|
||||||
|
|
||||||
|
if (isClass && items.isNotEmpty) {
|
||||||
|
children.add(_buildSubheading(context, 'Awarded'));
|
||||||
|
children.addAll(
|
||||||
|
items.map(
|
||||||
|
(award) => Padding(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
|
||||||
|
child: _buildAwardCard(context, award, compact: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (badgeAwardsHasMore || badgeAwardsLoading || _loadingAwards) {
|
||||||
|
children.add(
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4.0, bottom: 8.0),
|
||||||
|
child: _buildLoadMoreButton(
|
||||||
|
context,
|
||||||
|
badgeAwardsLoading || _loadingAwards,
|
||||||
|
() => _loadMoreAwards(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (!isClass && !isLoco && items.isNotEmpty) {
|
||||||
|
children.add(_buildSubheading(context, 'Awarded'));
|
||||||
|
children.addAll(
|
||||||
|
items.map(
|
||||||
|
(award) => Padding(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
|
||||||
|
child: _buildAwardCard(context, award, compact: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isClass) {
|
||||||
|
children.addAll(
|
||||||
|
_buildClassProgressSection(
|
||||||
|
context,
|
||||||
|
classItems,
|
||||||
|
classProgressLoading,
|
||||||
|
classProgressHasMore,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isLoco) {
|
||||||
|
children.addAll(
|
||||||
|
_buildLocoProgressSection(
|
||||||
|
context,
|
||||||
|
locoItems,
|
||||||
|
locoProgressLoading,
|
||||||
|
locoProgressHasMore,
|
||||||
|
showHeading: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (children.isEmpty && !isLoadingSection) {
|
||||||
|
children.add(
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 6.0),
|
||||||
|
child: Text('No awards'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 4.0),
|
||||||
|
child: ExpansionTile(
|
||||||
|
key: ValueKey(code),
|
||||||
|
tilePadding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: Text(title)),
|
||||||
|
if (isLoadingSection) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const SizedBox(
|
||||||
|
height: 18,
|
||||||
|
width: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_buildCountChip(context, awardCount),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
initiallyExpanded: expanded,
|
||||||
|
onExpansionChanged: (isOpen) {
|
||||||
|
setState(() => _groupExpanded[code] = isOpen);
|
||||||
|
},
|
||||||
|
children: children,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, List<BadgeAward>> _groupAwards(List<BadgeAward> awards) {
|
||||||
|
final Map<String, List<BadgeAward>> grouped = {};
|
||||||
|
for (final award in awards) {
|
||||||
|
final code = award.badgeCode.toLowerCase();
|
||||||
|
grouped.putIfAbsent(code, () => []).add(award);
|
||||||
|
}
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAwardCard(
|
||||||
|
BuildContext context,
|
||||||
|
BadgeAward award, {
|
||||||
|
bool compact = false,
|
||||||
|
}) {
|
||||||
|
final badgeName = _formatBadgeName(award.badgeCode);
|
||||||
|
final tier = award.badgeTier.isNotEmpty
|
||||||
|
? award.badgeTier[0].toUpperCase() + award.badgeTier.substring(1)
|
||||||
|
: '';
|
||||||
|
final tierIcon = _buildTierIcon(award.badgeTier);
|
||||||
|
final scope = _scopeToShow(award);
|
||||||
|
|
||||||
|
final content = Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
if (tierIcon != null) ...[
|
||||||
|
tierIcon,
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'$badgeName • $tier',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (award.awardedAt != null)
|
||||||
|
Text(
|
||||||
|
_formatAwardDate(award.awardedAt!),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (scope != null && scope.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
scope,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (award.loco != null) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_buildLocoInfo(context, award.loco!),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(10.0),
|
||||||
|
child: content,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLocoInfo(BuildContext context, LocoSummary loco) {
|
||||||
|
final lines = <String>[];
|
||||||
|
final classNum = [
|
||||||
|
if (loco.locoClass.isNotEmpty) loco.locoClass,
|
||||||
|
if (loco.number.isNotEmpty) loco.number,
|
||||||
|
].join(' ');
|
||||||
|
if (classNum.isNotEmpty) lines.add(classNum);
|
||||||
|
if ((loco.name ?? '').isNotEmpty) lines.add(loco.name!);
|
||||||
|
if ((loco.livery ?? '').isNotEmpty) lines.add(loco.livery!);
|
||||||
|
if ((loco.location ?? '').isNotEmpty) lines.add(loco.location!);
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.train, size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: lines.map((line) {
|
||||||
|
return Text(
|
||||||
|
line,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatBadgeName(String code) {
|
||||||
|
if (code.isEmpty) return 'Badge';
|
||||||
|
const known = {
|
||||||
|
'class_clearance': 'Class Clearance',
|
||||||
|
'loco_clearance': 'Loco Clearance',
|
||||||
|
};
|
||||||
|
final lower = code.toLowerCase();
|
||||||
|
if (known.containsKey(lower)) return known[lower]!;
|
||||||
|
final parts = code.split(RegExp(r'[_\\s]+')).where((p) => p.isNotEmpty);
|
||||||
|
return parts
|
||||||
|
.map((p) => p[0].toUpperCase() + p.substring(1).toLowerCase())
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> _orderedBadgeCodes(List<String> codes) {
|
||||||
|
final lowerCodes = codes.map((c) => c.toLowerCase()).toSet();
|
||||||
|
final ordered = <String>[];
|
||||||
|
for (final code in ['loco_clearance', 'class_clearance']) {
|
||||||
|
if (lowerCodes.remove(code)) ordered.add(code);
|
||||||
|
}
|
||||||
|
final remaining = lowerCodes.toList()
|
||||||
|
..sort((a, b) => _formatBadgeName(a).compareTo(_formatBadgeName(b)));
|
||||||
|
ordered.addAll(remaining);
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSubheading(BuildContext context, String label) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.labelMedium
|
||||||
|
?.copyWith(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildClassProgressSection(
|
||||||
|
BuildContext context,
|
||||||
|
List<ClassClearanceProgress> progress,
|
||||||
|
bool isLoading,
|
||||||
|
bool hasMore,
|
||||||
|
) {
|
||||||
|
if (progress.isEmpty && !isLoading && !hasMore) return const [];
|
||||||
|
return [
|
||||||
|
_buildSubheading(context, 'In Progress'),
|
||||||
|
...progress.map(
|
||||||
|
(item) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0),
|
||||||
|
child: _buildClassProgressCard(context, item),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (hasMore || isLoading)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4.0, bottom: 8.0),
|
||||||
|
child: _buildLoadMoreButton(
|
||||||
|
context,
|
||||||
|
isLoading,
|
||||||
|
() => _loadMoreClassProgress(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (progress.isNotEmpty) const SizedBox(height: 4),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildLocoProgressSection(
|
||||||
|
BuildContext context,
|
||||||
|
List<LocoClearanceProgress> progress,
|
||||||
|
bool isLoading,
|
||||||
|
bool hasMore,
|
||||||
|
{bool showHeading = true}
|
||||||
|
) {
|
||||||
|
if (progress.isEmpty && !isLoading && !hasMore) return const [];
|
||||||
|
return [
|
||||||
|
if (showHeading) _buildSubheading(context, 'In Progress'),
|
||||||
|
if (progress.isEmpty && isLoading)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12.0),
|
||||||
|
child: _buildLoadingIndicator(),
|
||||||
|
),
|
||||||
|
...progress.map(
|
||||||
|
(item) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 6.0),
|
||||||
|
child: _buildLocoProgressCard(context, item),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (hasMore || isLoading)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4.0, bottom: 8.0),
|
||||||
|
child: _buildLoadMoreButton(
|
||||||
|
context,
|
||||||
|
isLoading,
|
||||||
|
() => _loadMoreLocoProgress(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (progress.isNotEmpty) const SizedBox(height: 4),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildClassProgressCard(
|
||||||
|
BuildContext context,
|
||||||
|
ClassClearanceProgress progress,
|
||||||
|
) {
|
||||||
|
final pct = progress.percentComplete.clamp(0, 100);
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 4.0),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
progress.className,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${pct.toStringAsFixed(0)}%',
|
||||||
|
style: Theme.of(context).textTheme.labelMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
LinearProgressIndicator(
|
||||||
|
value: progress.total == 0 ? 0 : pct / 100,
|
||||||
|
minHeight: 6,
|
||||||
|
),
|
||||||
|
if (progress.total > 0)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2.0),
|
||||||
|
child: Text(
|
||||||
|
'${progress.completed}/${progress.total}',
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.labelSmall
|
||||||
|
?.copyWith(color: Theme.of(context).hintColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLocoProgressCard(
|
||||||
|
BuildContext context,
|
||||||
|
LocoClearanceProgress progress,
|
||||||
|
) {
|
||||||
|
final tierIcons = progress.awardedTiers
|
||||||
|
.map((tier) => _buildTierIcon(tier, size: 18))
|
||||||
|
.whereType<Widget>()
|
||||||
|
.toList();
|
||||||
|
final reachedTopTier = progress.nextTier.isEmpty;
|
||||||
|
final pct = progress.percent.clamp(0, 100);
|
||||||
|
final nextTier = progress.nextTier.isNotEmpty
|
||||||
|
? progress.nextTier[0].toUpperCase() + progress.nextTier.substring(1)
|
||||||
|
: 'Next';
|
||||||
|
final loco = progress.loco;
|
||||||
|
final title = [
|
||||||
|
if (loco.number.isNotEmpty) loco.number,
|
||||||
|
if (loco.locoClass.isNotEmpty) loco.locoClass,
|
||||||
|
].join(' • ');
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 4.0),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(10.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title.isNotEmpty ? title : 'Loco',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (tierIcons.isNotEmpty)
|
||||||
|
Row(
|
||||||
|
children: tierIcons
|
||||||
|
.expand((icon) sync* {
|
||||||
|
yield icon;
|
||||||
|
yield const SizedBox(width: 4);
|
||||||
|
})
|
||||||
|
.toList()
|
||||||
|
..removeLast(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if ((loco.name ?? '').isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2.0),
|
||||||
|
child: Text(
|
||||||
|
loco.name ?? '',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!reachedTopTier) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
LinearProgressIndicator(
|
||||||
|
value: progress.required == 0 ? 0 : pct / 100,
|
||||||
|
minHeight: 6,
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2.0),
|
||||||
|
child: Text(
|
||||||
|
'${pct.toStringAsFixed(0)}% to $nextTier award',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLoadMoreButton(
|
||||||
|
BuildContext context,
|
||||||
|
bool isLoading,
|
||||||
|
Future<void> Function() onPressed,
|
||||||
|
) {
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: isLoading
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
onPressed();
|
||||||
|
},
|
||||||
|
icon: isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
height: 18,
|
||||||
|
width: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.expand_more),
|
||||||
|
label: Text(isLoading ? 'Loading...' : 'Load more'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLoadingIndicator() {
|
||||||
|
return const Center(
|
||||||
|
child: SizedBox(
|
||||||
|
height: 24,
|
||||||
|
width: 24,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCountChip(BuildContext context, int count) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'$count',
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.labelMedium
|
||||||
|
?.copyWith(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadMoreClassProgress() {
|
||||||
|
final data = context.read<DataService>();
|
||||||
|
if (data.isClassClearanceProgressLoading || _loadingClassProgress) {
|
||||||
|
return Future.value();
|
||||||
|
}
|
||||||
|
setState(() => _loadingClassProgress = true);
|
||||||
|
return data
|
||||||
|
.fetchClassClearanceProgress(
|
||||||
|
offset: data.classClearanceProgress.length,
|
||||||
|
append: true,
|
||||||
|
)
|
||||||
|
.whenComplete(() {
|
||||||
|
if (mounted) setState(() => _loadingClassProgress = false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadMoreLocoProgress() {
|
||||||
|
final data = context.read<DataService>();
|
||||||
|
if (data.isLocoClearanceProgressLoading || _loadingLocoProgress) {
|
||||||
|
return Future.value();
|
||||||
|
}
|
||||||
|
setState(() => _loadingLocoProgress = true);
|
||||||
|
return data
|
||||||
|
.fetchLocoClearanceProgress(
|
||||||
|
offset: data.locoClearanceProgress.length,
|
||||||
|
append: true,
|
||||||
|
)
|
||||||
|
.whenComplete(() {
|
||||||
|
if (mounted) setState(() => _loadingLocoProgress = false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadMoreAwards() {
|
||||||
|
final data = context.read<DataService>();
|
||||||
|
if (data.isBadgeAwardsLoading || _loadingAwards) return Future.value();
|
||||||
|
setState(() => _loadingAwards = true);
|
||||||
|
return data
|
||||||
|
.fetchBadgeAwards(
|
||||||
|
offset: data.badgeAwards.length,
|
||||||
|
append: true,
|
||||||
|
badgeCode: 'class_clearance',
|
||||||
|
limit: 20,
|
||||||
|
)
|
||||||
|
.whenComplete(() {
|
||||||
|
if (mounted) setState(() => _loadingAwards = false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatAwardDate(DateTime date) {
|
||||||
|
final y = date.year.toString().padLeft(4, '0');
|
||||||
|
final m = date.month.toString().padLeft(2, '0');
|
||||||
|
final d = date.day.toString().padLeft(2, '0');
|
||||||
|
return '$y-$m-$d';
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget? _buildTierIcon(String tier, {double size = 24}) {
|
||||||
|
final lower = tier.toLowerCase();
|
||||||
|
Color? color;
|
||||||
|
switch (lower) {
|
||||||
|
case 'bronze':
|
||||||
|
color = const Color(0xFFCD7F32);
|
||||||
|
break;
|
||||||
|
case 'silver':
|
||||||
|
color = const Color(0xFFC0C0C0);
|
||||||
|
break;
|
||||||
|
case 'gold':
|
||||||
|
color = const Color(0xFFFFD700);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (color == null) return null;
|
||||||
|
return Icon(Icons.emoji_events, color: color, size: size);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _scopeToShow(BadgeAward award) {
|
||||||
|
final scope = award.scopeValue?.trim() ?? '';
|
||||||
|
if (scope.isEmpty) return null;
|
||||||
|
final code = award.badgeCode.toLowerCase();
|
||||||
|
if (code == 'loco_clearance') {
|
||||||
|
// Hide numeric loco IDs; loco details are shown separately.
|
||||||
|
if (int.tryParse(scope) != null) return null;
|
||||||
|
}
|
||||||
|
return scope;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,10 +20,16 @@ class MoreHomePage extends StatelessWidget {
|
|||||||
Card(
|
Card(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.person),
|
||||||
|
title: const Text('Profile'),
|
||||||
|
onTap: () => context.go('/more/profile'),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.emoji_events),
|
leading: const Icon(Icons.emoji_events),
|
||||||
title: const Text('Badges'),
|
title: const Text('Badges'),
|
||||||
onTap: () => context.go('/more/profile'),
|
onTap: () => context.go('/more/badges'),
|
||||||
),
|
),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
ListTile(
|
ListTile(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import 'package:mileograph_flutter/components/calculator/calculator.dart';
|
|||||||
import 'package:mileograph_flutter/components/pages/traction.dart';
|
import 'package:mileograph_flutter/components/pages/traction.dart';
|
||||||
import 'package:mileograph_flutter/objects/objects.dart';
|
import 'package:mileograph_flutter/objects/objects.dart';
|
||||||
import 'package:mileograph_flutter/services/api_service.dart';
|
import 'package:mileograph_flutter/services/api_service.dart';
|
||||||
|
import 'package:mileograph_flutter/services/authservice.dart';
|
||||||
import 'package:mileograph_flutter/services/data_service.dart';
|
import 'package:mileograph_flutter/services/data_service.dart';
|
||||||
import 'package:mileograph_flutter/services/distance_unit_service.dart';
|
import 'package:mileograph_flutter/services/distance_unit_service.dart';
|
||||||
import 'package:mileograph_flutter/services/navigation_guard.dart';
|
import 'package:mileograph_flutter/services/navigation_guard.dart';
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
|
|||||||
Future<bool> _handleExitIntent() async {
|
Future<bool> _handleExitIntent() async {
|
||||||
if (!mounted) return false;
|
if (!mounted) return false;
|
||||||
if (_isEditing) return true;
|
if (_isEditing) return true;
|
||||||
|
if (_activeLegShare != null) return true;
|
||||||
if (_formIsEmpty()) return true;
|
if (_formIsEmpty()) return true;
|
||||||
if (_activeDraftId != null && !_draftChangedFromBaseline()) {
|
if (_activeDraftId != null && !_draftChangedFromBaseline()) {
|
||||||
return true;
|
return true;
|
||||||
@@ -82,6 +83,7 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openDrafts() async {
|
Future<void> _openDrafts() async {
|
||||||
|
if (_activeLegShare != null) return;
|
||||||
final selected = await Navigator.of(context).push<_StoredDraft>(
|
final selected = await Navigator.of(context).push<_StoredDraft>(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => _DraftListPage(
|
builder: (_) => _DraftListPage(
|
||||||
@@ -97,6 +99,7 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _saveDraftManually() async {
|
Future<void> _saveDraftManually() async {
|
||||||
|
if (_activeLegShare != null) return;
|
||||||
if (_savingDraft) return;
|
if (_savingDraft) return;
|
||||||
if (_formIsEmpty()) {
|
if (_formIsEmpty()) {
|
||||||
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
|
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
|
||||||
@@ -123,7 +126,9 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _saveDraft() async {
|
Future<void> _saveDraft() async {
|
||||||
if (_restoringDraft || !_draftPersistenceEnabled) return;
|
if (_restoringDraft || !_draftPersistenceEnabled || _activeLegShare != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final draft = {
|
final draft = {
|
||||||
"date": _selectedDate.toIso8601String(),
|
"date": _selectedDate.toIso8601String(),
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
part of 'new_entry.dart';
|
part of 'new_entry.dart';
|
||||||
|
|
||||||
class NewEntryPage extends StatefulWidget {
|
class NewEntryPage extends StatefulWidget {
|
||||||
const NewEntryPage({super.key, this.editLegId});
|
const NewEntryPage({super.key, this.editLegId, this.legShare});
|
||||||
|
|
||||||
final int? editLegId;
|
final int? editLegId;
|
||||||
|
final LegShareData? legShare;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<NewEntryPage> createState() => _NewEntryPageState();
|
State<NewEntryPage> createState() => _NewEntryPageState();
|
||||||
@@ -52,9 +53,15 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
const DeepCollectionEquality();
|
const DeepCollectionEquality();
|
||||||
String? _activeDraftId;
|
String? _activeDraftId;
|
||||||
DistanceUnit? _lastDistanceUnit;
|
DistanceUnit? _lastDistanceUnit;
|
||||||
|
Set<String> _shareUserIds = {};
|
||||||
|
List<UserSummary> _shareUsers = [];
|
||||||
|
LegShareData? _activeLegShare;
|
||||||
|
String? _sharedFromUser;
|
||||||
|
int? _shareNotificationId;
|
||||||
|
|
||||||
bool get _isEditing => widget.editLegId != null;
|
bool get _isEditing => widget.editLegId != null;
|
||||||
bool get _draftPersistenceEnabled =>
|
bool get _draftPersistenceEnabled =>
|
||||||
|
_activeLegShare == null &&
|
||||||
false; // legacy single draft disabled in favor of draft list
|
false; // legacy single draft disabled in favor of draft list
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -69,16 +76,28 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
data.fetchClassList();
|
data.fetchClassList();
|
||||||
data.fetchTripOptions();
|
data.fetchTripOptions();
|
||||||
_loadStations();
|
_loadStations();
|
||||||
if (_draftPersistenceEnabled) {
|
if (_draftPersistenceEnabled && widget.legShare == null) {
|
||||||
_loadDraft();
|
_loadDraft();
|
||||||
}
|
}
|
||||||
_loadStations();
|
_loadStations();
|
||||||
if (_isEditing && widget.editLegId != null) {
|
if (_isEditing && widget.editLegId != null) {
|
||||||
_loadLegForEdit(widget.editLegId!);
|
_loadLegForEdit(widget.editLegId!);
|
||||||
|
} else if (widget.legShare != null) {
|
||||||
|
_applyLegShare(widget.legShare!);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant NewEntryPage oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (widget.legShare != null &&
|
||||||
|
(oldWidget.legShare == null ||
|
||||||
|
widget.legShare!.id != oldWidget.legShare!.id)) {
|
||||||
|
_applyLegShare(widget.legShare!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
NavigationGuard.unregister(_exitGuard);
|
NavigationGuard.unregister(_exitGuard);
|
||||||
@@ -134,6 +153,282 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
0;
|
0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<UserSummary> _friendsFromFriendships(
|
||||||
|
DataService data,
|
||||||
|
String? selfId,
|
||||||
|
) {
|
||||||
|
final friends = <UserSummary>[];
|
||||||
|
for (final f in data.friendships) {
|
||||||
|
final other = _friendFromFriendship(f, selfId);
|
||||||
|
if (other != null &&
|
||||||
|
other.userId.isNotEmpty &&
|
||||||
|
!friends.any((u) => u.userId == other.userId)) {
|
||||||
|
friends.add(other);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return friends;
|
||||||
|
}
|
||||||
|
|
||||||
|
UserSummary? _friendFromFriendship(Friendship friendship, String? selfId) {
|
||||||
|
final self = selfId ?? '';
|
||||||
|
if (friendship.requesterId == self) return friendship.addressee;
|
||||||
|
if (friendship.addresseeId == self) return friendship.requester;
|
||||||
|
if (friendship.addressee != null) return friendship.addressee;
|
||||||
|
if (friendship.requester != null) return friendship.requester;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildShareSection(BuildContext context) {
|
||||||
|
final selected = _shareUsers;
|
||||||
|
final label = selected.isEmpty
|
||||||
|
? 'Share with friends'
|
||||||
|
: 'Shared with ${selected.length} friend${selected.length == 1 ? '' : 's'}';
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _submitting ? null : _openShareSheet,
|
||||||
|
icon: const Icon(Icons.share),
|
||||||
|
label: Text(label),
|
||||||
|
),
|
||||||
|
if (selected.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: selected
|
||||||
|
.map(
|
||||||
|
(u) => InputChip(
|
||||||
|
label: Text(u.displayName),
|
||||||
|
avatar: const Icon(Icons.person, size: 16),
|
||||||
|
onDeleted: _submitting
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
setState(() {
|
||||||
|
_shareUserIds.remove(u.userId);
|
||||||
|
_shareUsers.removeWhere(
|
||||||
|
(item) => item.userId == u.userId,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSharedBanner() {
|
||||||
|
final from = _sharedFromUser;
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.orange.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.orange.withValues(alpha: 0.4)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.share, color: Colors.orange),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
from != null && from.isNotEmpty
|
||||||
|
? 'This entry was shared by $from.'
|
||||||
|
: 'This entry was shared with you.',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openShareSheet() async {
|
||||||
|
if (_activeLegShare != null) return;
|
||||||
|
final data = context.read<DataService>();
|
||||||
|
final auth = context.read<AuthService>();
|
||||||
|
try {
|
||||||
|
await data.fetchFriendships();
|
||||||
|
} catch (_) {}
|
||||||
|
if (!mounted) return;
|
||||||
|
final baseFriends = _friendsFromFriendships(data, auth.userId);
|
||||||
|
final initialSelectedIds = {..._shareUserIds};
|
||||||
|
final initialSelectedUsers = {
|
||||||
|
for (final u in _shareUsers) u.userId: u,
|
||||||
|
};
|
||||||
|
|
||||||
|
final result = await showModalBottomSheet<List<UserSummary>>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (ctx) {
|
||||||
|
final searchController = TextEditingController();
|
||||||
|
List<UserSummary> searchResults = [];
|
||||||
|
bool searching = false;
|
||||||
|
String searchTerm = '';
|
||||||
|
String? searchError;
|
||||||
|
final selectedIds = {...initialSelectedIds};
|
||||||
|
final selectedUsers = {...initialSelectedUsers};
|
||||||
|
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (modalContext, setModalState) {
|
||||||
|
Future<void> runSearch(String term) async {
|
||||||
|
final trimmed = term.trim();
|
||||||
|
if (trimmed.isEmpty) {
|
||||||
|
setModalState(() {
|
||||||
|
searchTerm = '';
|
||||||
|
searchResults = [];
|
||||||
|
searchError = null;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setModalState(() {
|
||||||
|
searchTerm = trimmed;
|
||||||
|
searching = true;
|
||||||
|
searchError = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final results =
|
||||||
|
await data.searchUsers(trimmed, friendsOnly: true);
|
||||||
|
setModalState(() {
|
||||||
|
searchResults = results;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setModalState(() {
|
||||||
|
searchError = 'Search failed';
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setModalState(() {
|
||||||
|
searching = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final list = searchTerm.isNotEmpty ? searchResults : baseFriends;
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.of(modalContext).viewInsets.bottom + 16,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
top: 16,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Share with friends',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => Navigator.of(modalContext).pop(),
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
controller: searchController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Search friends',
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: const Icon(Icons.search),
|
||||||
|
onPressed: () => runSearch(searchController.text),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onSubmitted: runSearch,
|
||||||
|
),
|
||||||
|
if (searching)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(top: 8.0),
|
||||||
|
child: LinearProgressIndicator(minHeight: 2),
|
||||||
|
),
|
||||||
|
if (searchError != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
|
child: Text(
|
||||||
|
searchError!,
|
||||||
|
style: const TextStyle(color: Colors.red),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SizedBox(
|
||||||
|
height: 320,
|
||||||
|
child: list.isEmpty
|
||||||
|
? const Center(child: Text('No friends found'))
|
||||||
|
: ListView.builder(
|
||||||
|
itemCount: list.length,
|
||||||
|
itemBuilder: (_, index) {
|
||||||
|
final user = list[index];
|
||||||
|
final isSelected =
|
||||||
|
selectedIds.contains(user.userId);
|
||||||
|
return CheckboxListTile(
|
||||||
|
value: isSelected,
|
||||||
|
title: Text(user.displayName),
|
||||||
|
subtitle: user.username.isNotEmpty
|
||||||
|
? Text('@${user.username}')
|
||||||
|
: null,
|
||||||
|
onChanged: (checked) {
|
||||||
|
setModalState(() {
|
||||||
|
if (checked == true) {
|
||||||
|
selectedIds.add(user.userId);
|
||||||
|
selectedUsers[user.userId] = user;
|
||||||
|
} else {
|
||||||
|
selectedIds.remove(user.userId);
|
||||||
|
selectedUsers.remove(user.userId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(modalContext).pop(),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.of(modalContext).pop(
|
||||||
|
selectedIds
|
||||||
|
.map((id) => selectedUsers[id])
|
||||||
|
.whereType<UserSummary>()
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
child: const Text('Save'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result != null && mounted) {
|
||||||
|
setState(() {
|
||||||
|
_shareUsers = result;
|
||||||
|
_shareUserIds = result.map((u) => u.userId).toSet();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _syncManualFieldUnit(DistanceUnit currentUnit) {
|
void _syncManualFieldUnit(DistanceUnit currentUnit) {
|
||||||
if (!_useManualMileage) {
|
if (!_useManualMileage) {
|
||||||
_lastDistanceUnit = currentUnit;
|
_lastDistanceUnit = currentUnit;
|
||||||
@@ -571,6 +866,96 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _applyLegShare(LegShareData share) {
|
||||||
|
final entry = share.entry;
|
||||||
|
final beginTime = entry.beginTime;
|
||||||
|
final endTime = entry.endTime;
|
||||||
|
final routeStations = entry.route;
|
||||||
|
final mileageVal = entry.mileage;
|
||||||
|
final units = _distanceUnits(context);
|
||||||
|
final useManual = routeStations.isEmpty;
|
||||||
|
final routeResult = useManual
|
||||||
|
? null
|
||||||
|
: RouteResult(
|
||||||
|
inputRoute: routeStations,
|
||||||
|
calculatedRoute: routeStations,
|
||||||
|
costs: const <double>[],
|
||||||
|
distance: mileageVal,
|
||||||
|
);
|
||||||
|
final tractionItems = _buildTractionFromApi(
|
||||||
|
entry.locos
|
||||||
|
.map((l) => {
|
||||||
|
"loco_id": l.id,
|
||||||
|
"type": l.type,
|
||||||
|
"number": l.number,
|
||||||
|
"class": l.locoClass,
|
||||||
|
"name": l.name,
|
||||||
|
"operator": l.operator,
|
||||||
|
"notes": l.notes,
|
||||||
|
"evn": l.evn,
|
||||||
|
"alloc_pos": l.allocPos,
|
||||||
|
"alloc_powering": l.powering ? 1 : 0,
|
||||||
|
})
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
final beginDelay = entry.beginDelayMinutes ?? 0;
|
||||||
|
final endDelay = entry.endDelayMinutes ?? 0;
|
||||||
|
final originTime = entry.originTime;
|
||||||
|
final destinationTime = entry.destinationTime;
|
||||||
|
final hasOriginTime = originTime != null;
|
||||||
|
final hasDestinationTime = destinationTime != null;
|
||||||
|
final hasEndTime = endTime != null || endDelay != 0;
|
||||||
|
|
||||||
|
_restoringDraft = true;
|
||||||
|
setState(() {
|
||||||
|
_activeLegShare = share;
|
||||||
|
_sharedFromUser = share.sharedFromName;
|
||||||
|
_shareNotificationId = share.notificationId;
|
||||||
|
_selectedTripId = null;
|
||||||
|
_selectedDate = beginTime;
|
||||||
|
_selectedTime = TimeOfDay.fromDateTime(beginTime);
|
||||||
|
_selectedEndDate = endTime ?? beginTime;
|
||||||
|
_selectedEndTime = TimeOfDay.fromDateTime(endTime ?? beginTime);
|
||||||
|
_hasEndTime = hasEndTime;
|
||||||
|
_selectedOriginDate = originTime ?? beginTime;
|
||||||
|
_selectedOriginTime = TimeOfDay.fromDateTime(originTime ?? beginTime);
|
||||||
|
_selectedDestinationDate = destinationTime ?? endTime ?? beginTime;
|
||||||
|
_selectedDestinationTime =
|
||||||
|
TimeOfDay.fromDateTime(destinationTime ?? endTime ?? beginTime);
|
||||||
|
_hasOriginTime = hasOriginTime;
|
||||||
|
_hasDestinationTime = hasDestinationTime;
|
||||||
|
_useManualMileage = useManual;
|
||||||
|
_routeResult = routeResult;
|
||||||
|
_startController.text = entry.start;
|
||||||
|
_endController.text = entry.end;
|
||||||
|
_headcodeController.text = entry.headcode.toUpperCase();
|
||||||
|
_notesController.text = entry.notes;
|
||||||
|
_networkController.text = entry.network.toUpperCase();
|
||||||
|
_originController.text = entry.origin;
|
||||||
|
_destinationController.text = entry.destination;
|
||||||
|
_beginDelayController.text = beginDelay.toString();
|
||||||
|
_endDelayController.text = endDelay.toString();
|
||||||
|
_mileageController.text = mileageVal == 0
|
||||||
|
? ''
|
||||||
|
: _formatDistance(
|
||||||
|
units,
|
||||||
|
mileageVal,
|
||||||
|
decimals: 2,
|
||||||
|
includeUnit: false,
|
||||||
|
);
|
||||||
|
_tractionItems
|
||||||
|
..clear()
|
||||||
|
..addAll(tractionItems);
|
||||||
|
if (_tractionItems.where((e) => e.isMarker).isEmpty) {
|
||||||
|
_tractionItems.insert(0, _TractionItem.marker());
|
||||||
|
}
|
||||||
|
_lastSubmittedSnapshot = null;
|
||||||
|
_shareUserIds.clear();
|
||||||
|
_shareUsers.clear();
|
||||||
|
});
|
||||||
|
_restoringDraft = false;
|
||||||
|
}
|
||||||
|
|
||||||
List<String> _parseRouteStations(dynamic raw) {
|
List<String> _parseRouteStations(dynamic raw) {
|
||||||
if (raw is List) {
|
if (raw is List) {
|
||||||
return raw.map((e) => e.toString()).toList();
|
return raw.map((e) => e.toString()).toList();
|
||||||
@@ -809,7 +1194,8 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
minimumSize: const Size(0, 36),
|
minimumSize: const Size(0, 36),
|
||||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
),
|
),
|
||||||
onPressed: _isEditing ? null : _openDrafts,
|
onPressed:
|
||||||
|
_isEditing || _activeLegShare != null ? null : _openDrafts,
|
||||||
icon: const Icon(Icons.list_alt, size: 16),
|
icon: const Icon(Icons.list_alt, size: 16),
|
||||||
label: const Text('Drafts'),
|
label: const Text('Drafts'),
|
||||||
),
|
),
|
||||||
@@ -817,23 +1203,26 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
minimumSize: const Size(0, 36),
|
minimumSize: const Size(0, 36),
|
||||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
),
|
),
|
||||||
onPressed: _isEditing || _savingDraft || _submitting
|
onPressed: _isEditing ||
|
||||||
? null
|
_savingDraft ||
|
||||||
: _saveDraftManually,
|
_submitting ||
|
||||||
icon: _savingDraft
|
_activeLegShare != null
|
||||||
? const SizedBox(
|
? null
|
||||||
width: 16,
|
: _saveDraftManually,
|
||||||
height: 16,
|
icon: _savingDraft
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
)
|
)
|
||||||
: const Icon(Icons.save_alt, size: 16),
|
: const Icon(Icons.save_alt, size: 16),
|
||||||
label: Text(_savingDraft ? 'Saving...' : 'Save to drafts'),
|
label: Text(_savingDraft ? 'Saving...' : 'Save to drafts'),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
minimumSize: const Size(0, 36),
|
minimumSize: const Size(0, 36),
|
||||||
@@ -844,11 +1233,17 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
: () => _resetFormState(clearDraft: true),
|
: () => _resetFormState(clearDraft: true),
|
||||||
icon: const Icon(Icons.clear, size: 16),
|
icon: const Icon(Icons.clear, size: 16),
|
||||||
label: const Text('Clear form'),
|
label: const Text('Clear form'),
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
_buildTripSelector(context),
|
],
|
||||||
_dateTimeGroup(
|
),
|
||||||
|
if (_activeLegShare != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildSharedBanner(),
|
||||||
|
],
|
||||||
|
_buildTripSelector(context),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (_activeLegShare == null) _buildShareSection(context),
|
||||||
|
_dateTimeGroup(
|
||||||
context,
|
context,
|
||||||
title: 'Departure time',
|
title: 'Departure time',
|
||||||
onDateTap: _pickDate,
|
onDateTap: _pickDate,
|
||||||
@@ -1100,11 +1495,15 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
)
|
)
|
||||||
: const Icon(Icons.send),
|
: const Icon(Icons.send),
|
||||||
label: Text(
|
label: () {
|
||||||
_submitting
|
final shareMode = _activeLegShare != null;
|
||||||
? (_isEditing ? 'Saving...' : 'Submitting...')
|
if (_submitting) {
|
||||||
: (_isEditing ? 'Save changes' : 'Submit entry'),
|
if (shareMode) return const Text('Accepting...');
|
||||||
),
|
return Text(_isEditing ? 'Saving...' : 'Submitting...');
|
||||||
|
}
|
||||||
|
if (shareMode) return const Text('Accept entry');
|
||||||
|
return Text(_isEditing ? 'Save changes' : 'Submit entry');
|
||||||
|
}(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
|
|||||||
"leg_begin_delay": beginDelay,
|
"leg_begin_delay": beginDelay,
|
||||||
if (_hasEndTime) "leg_end_delay": endDelay,
|
if (_hasEndTime) "leg_end_delay": endDelay,
|
||||||
"locos": tractionPayload,
|
"locos": tractionPayload,
|
||||||
|
if (_activeLegShare != null) "leg_share_id": _activeLegShare!.id,
|
||||||
|
"share_user_ids": _shareUserIds.toList(),
|
||||||
};
|
};
|
||||||
if (_useManualMileage) {
|
if (_useManualMileage) {
|
||||||
final body = {
|
final body = {
|
||||||
@@ -136,6 +138,9 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
dataService.refreshLegs();
|
dataService.refreshLegs();
|
||||||
await dataService.fetchNotifications();
|
await dataService.fetchNotifications();
|
||||||
|
if (_shareNotificationId != null) {
|
||||||
|
await dataService.dismissNotifications([_shareNotificationId!]);
|
||||||
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
messenger?.showSnackBar(
|
messenger?.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
@@ -188,6 +193,8 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
|
|||||||
"headcode": _headcodeController.text.trim(),
|
"headcode": _headcodeController.text.trim(),
|
||||||
"beginDelay": beginDelay,
|
"beginDelay": beginDelay,
|
||||||
"endDelay": endDelay,
|
"endDelay": endDelay,
|
||||||
|
"legShareId": _activeLegShare?.id,
|
||||||
|
"shareUserIds": _shareUserIds.toList(),
|
||||||
"locos": tractionPayload,
|
"locos": tractionPayload,
|
||||||
"routeResult": _routeResult == null
|
"routeResult": _routeResult == null
|
||||||
? null
|
? null
|
||||||
@@ -223,6 +230,7 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _resetFormState({bool clearDraft = false}) async {
|
Future<void> _resetFormState({bool clearDraft = false}) async {
|
||||||
|
final hadShare = _activeLegShare != null || widget.legShare != null;
|
||||||
_formKey.currentState?.reset();
|
_formKey.currentState?.reset();
|
||||||
_startController.clear();
|
_startController.clear();
|
||||||
_endController.clear();
|
_endController.clear();
|
||||||
@@ -252,6 +260,11 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
|
|||||||
_matchDestinationToEntry = false;
|
_matchDestinationToEntry = false;
|
||||||
_matchUpdateScheduled = false;
|
_matchUpdateScheduled = false;
|
||||||
_routeResult = null;
|
_routeResult = null;
|
||||||
|
_activeLegShare = null;
|
||||||
|
_sharedFromUser = null;
|
||||||
|
_shareNotificationId = null;
|
||||||
|
_shareUserIds.clear();
|
||||||
|
_shareUsers.clear();
|
||||||
_tractionItems
|
_tractionItems
|
||||||
..clear()
|
..clear()
|
||||||
..add(_TractionItem.marker());
|
..add(_TractionItem.marker());
|
||||||
@@ -261,6 +274,10 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
|
|||||||
_savingDraft = false;
|
_savingDraft = false;
|
||||||
_loadedDraftSnapshot = null;
|
_loadedDraftSnapshot = null;
|
||||||
});
|
});
|
||||||
|
if (hadShare && mounted) {
|
||||||
|
// Clear any share params from the URL when resetting.
|
||||||
|
GoRouter.of(context).go('/add');
|
||||||
|
}
|
||||||
if (clearDraft) {
|
if (clearDraft) {
|
||||||
await _clearDraft();
|
await _clearDraft();
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
127
lib/components/widgets/friend_request_notification_card.dart
Normal file
127
lib/components/widgets/friend_request_notification_card.dart
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:mileograph_flutter/objects/objects.dart';
|
||||||
|
import 'package:mileograph_flutter/services/data_service.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
class FriendRequestNotificationCard extends StatelessWidget {
|
||||||
|
const FriendRequestNotificationCard({super.key, required this.notification});
|
||||||
|
|
||||||
|
final UserNotification notification;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final data = context.read<DataService>();
|
||||||
|
final friendshipId = notification.body.trim();
|
||||||
|
if (friendshipId.isEmpty) {
|
||||||
|
return const Text('Invalid friend request notification.');
|
||||||
|
}
|
||||||
|
final future = data.fetchFriendshipById(friendshipId);
|
||||||
|
return FutureBuilder<Friendship?>(
|
||||||
|
future: future,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 6.0),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return const Text('Failed to load request details.');
|
||||||
|
}
|
||||||
|
final friendship = snapshot.data;
|
||||||
|
final requester = friendship?.requester;
|
||||||
|
if (friendship == null || requester == null) {
|
||||||
|
return const Text('Friend request details unavailable.');
|
||||||
|
}
|
||||||
|
final buttonStyle = ElevatedButton.styleFrom(
|
||||||
|
minimumSize: const Size(0, 36),
|
||||||
|
);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.person_add_alt, size: 18),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
requester.displayName.isNotEmpty
|
||||||
|
? requester.displayName
|
||||||
|
: '@${requester.username}',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
ElevatedButton(
|
||||||
|
style: buttonStyle,
|
||||||
|
onPressed: () =>
|
||||||
|
_respond(context, friendship.id, accept: true),
|
||||||
|
child: const Text('Accept'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
style: buttonStyle,
|
||||||
|
onPressed: () =>
|
||||||
|
_respond(context, friendship.id, accept: false),
|
||||||
|
child: const Text('Reject'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _respond(
|
||||||
|
BuildContext context,
|
||||||
|
String? friendshipId, {
|
||||||
|
required bool accept,
|
||||||
|
}) async {
|
||||||
|
if (friendshipId == null || friendshipId.isEmpty) return;
|
||||||
|
final data = context.read<DataService>();
|
||||||
|
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||||
|
try {
|
||||||
|
if (accept) {
|
||||||
|
await data.acceptFriendship(friendshipId);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
messenger?.showSnackBar(
|
||||||
|
const SnackBar(content: Text('Friend request accepted')),
|
||||||
|
);
|
||||||
|
await _dismissNotification(context, messenger);
|
||||||
|
} else {
|
||||||
|
await data.rejectFriendship(friendshipId);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
messenger?.showSnackBar(
|
||||||
|
const SnackBar(content: Text('Friend request rejected')),
|
||||||
|
);
|
||||||
|
await _dismissNotification(context, messenger);
|
||||||
|
}
|
||||||
|
await data.fetchPendingFriendships();
|
||||||
|
} catch (e) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
messenger?.showSnackBar(
|
||||||
|
SnackBar(content: Text('Failed to respond: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _dismissNotification(
|
||||||
|
BuildContext context,
|
||||||
|
ScaffoldMessengerState? messenger,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
await context.read<DataService>().dismissNotifications([notification.id]);
|
||||||
|
} catch (e) {
|
||||||
|
messenger?.showSnackBar(
|
||||||
|
SnackBar(content: Text('Failed to dismiss notification: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
147
lib/components/widgets/leg_share_notification_card.dart
Normal file
147
lib/components/widgets/leg_share_notification_card.dart
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:mileograph_flutter/objects/objects.dart';
|
||||||
|
import 'package:mileograph_flutter/services/data_service.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
class LegShareNotificationCard extends StatelessWidget {
|
||||||
|
const LegShareNotificationCard({super.key, required this.notification});
|
||||||
|
|
||||||
|
final UserNotification notification;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final data = context.read<DataService>();
|
||||||
|
final legShareId = notification.body.trim();
|
||||||
|
if (legShareId.isEmpty) {
|
||||||
|
return const Text('Invalid leg share notification.');
|
||||||
|
}
|
||||||
|
final future = data.fetchLegShare(legShareId);
|
||||||
|
return FutureBuilder<LegShareData?>(
|
||||||
|
future: future,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 6.0),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return const Text('Failed to load shared entry.');
|
||||||
|
}
|
||||||
|
final share = snapshot.data;
|
||||||
|
final entry = share?.entry;
|
||||||
|
if (share == null || entry == null) {
|
||||||
|
return const Text('Shared entry unavailable.');
|
||||||
|
}
|
||||||
|
final begin = entry.beginTime;
|
||||||
|
final beginStr =
|
||||||
|
'${begin.year.toString().padLeft(4, '0')}-${begin.month.toString().padLeft(2, '0')}-${begin.day.toString().padLeft(2, '0')} ${begin.hour.toString().padLeft(2, '0')}:${begin.minute.toString().padLeft(2, '0')}';
|
||||||
|
final start = entry.route.isNotEmpty ? entry.route.first : entry.start;
|
||||||
|
final end = entry.route.isNotEmpty ? entry.route.last : entry.end;
|
||||||
|
final sharedAt = share.sharedAt;
|
||||||
|
final sharedAtStr = sharedAt == null
|
||||||
|
? null
|
||||||
|
: '${sharedAt.year.toString().padLeft(4, '0')}-${sharedAt.month.toString().padLeft(2, '0')}-${sharedAt.day.toString().padLeft(2, '0')} ${sharedAt.hour.toString().padLeft(2, '0')}:${sharedAt.minute.toString().padLeft(2, '0')}';
|
||||||
|
final from = share.sharedFromName;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('$beginStr • $start → $end'),
|
||||||
|
if (from.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4.0),
|
||||||
|
child: Text(
|
||||||
|
'From $from',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (sharedAtStr != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4.0),
|
||||||
|
child: Text(
|
||||||
|
'Shared at $sharedAtStr',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => _accept(context, share),
|
||||||
|
child: const Text('Accept'),
|
||||||
|
),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: () => _inspect(context, share),
|
||||||
|
child: const Text('Inspect'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => _reject(context, share),
|
||||||
|
child: const Text('Reject'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _accept(BuildContext context, LegShareData share) async {
|
||||||
|
final data = context.read<DataService>();
|
||||||
|
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||||
|
try {
|
||||||
|
await data.acceptLegShare(share);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
await data.dismissNotifications([notification.id]);
|
||||||
|
// Refresh legs in the background.
|
||||||
|
unawaited(data.refreshLegs());
|
||||||
|
messenger?.showSnackBar(
|
||||||
|
const SnackBar(content: Text('Shared entry added to logbook')),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
messenger?.showSnackBar(
|
||||||
|
SnackBar(content: Text('Failed to add shared entry: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _reject(BuildContext context, LegShareData share) async {
|
||||||
|
final data = context.read<DataService>();
|
||||||
|
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||||
|
try {
|
||||||
|
await data.rejectLegShare(share.id);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
await data.dismissNotifications([notification.id]);
|
||||||
|
messenger?.showSnackBar(
|
||||||
|
const SnackBar(content: Text('Share rejected')),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
messenger?.showSnackBar(
|
||||||
|
SnackBar(content: Text('Failed to reject share: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _inspect(BuildContext context, LegShareData share) async {
|
||||||
|
final router = GoRouter.of(context);
|
||||||
|
// Close notifications panel if open.
|
||||||
|
if (Navigator.canPop(context)) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
final target = share.copyWith(notificationId: notification.id);
|
||||||
|
final ts = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final path = '/add?share=${Uri.encodeComponent(share.id)}&ts=$ts';
|
||||||
|
router.go(path, extra: target);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -164,6 +164,187 @@ class UserSummary extends UserData {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class Friendship {
|
||||||
|
final String? id;
|
||||||
|
final String status;
|
||||||
|
final String requesterId;
|
||||||
|
final String addresseeId;
|
||||||
|
final UserSummary? requester;
|
||||||
|
final UserSummary? addressee;
|
||||||
|
final DateTime? requestedAt;
|
||||||
|
final DateTime? respondedAt;
|
||||||
|
|
||||||
|
const Friendship({
|
||||||
|
required this.id,
|
||||||
|
required this.status,
|
||||||
|
required this.requesterId,
|
||||||
|
required this.addresseeId,
|
||||||
|
this.requester,
|
||||||
|
this.addressee,
|
||||||
|
this.requestedAt,
|
||||||
|
this.respondedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get _statusLower => status.toLowerCase();
|
||||||
|
bool get isNone => _statusLower == 'none';
|
||||||
|
bool get isPending => _statusLower == 'pending';
|
||||||
|
bool get isAccepted => _statusLower == 'accepted';
|
||||||
|
bool get isBlocked => _statusLower == 'blocked';
|
||||||
|
bool get isDeclined =>
|
||||||
|
_statusLower == 'declined' || _statusLower == 'rejected';
|
||||||
|
|
||||||
|
factory Friendship.fromJson(Map<String, dynamic> json) {
|
||||||
|
DateTime? parseDate(dynamic value) {
|
||||||
|
if (value is DateTime) return value;
|
||||||
|
return DateTime.tryParse(value?.toString() ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
String pickId(Map<String, dynamic> map, List<String> keys) {
|
||||||
|
for (final key in keys) {
|
||||||
|
final value = map[key];
|
||||||
|
if (value == null) continue;
|
||||||
|
final str = value.toString();
|
||||||
|
if (str.isNotEmpty) return str;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
UserSummary? parseUser(dynamic raw, {String? usernameKey, String? fullKey, String? idKey}) {
|
||||||
|
if (raw is Map) {
|
||||||
|
return UserSummary.fromJson(
|
||||||
|
raw.map((key, value) => MapEntry(key.toString(), value)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final username = usernameKey != null ? _asString(json[usernameKey]) : '';
|
||||||
|
final fullName = fullKey != null ? _asString(json[fullKey]) : '';
|
||||||
|
final id = idKey != null ? _asString(json[idKey]) : '';
|
||||||
|
if (username.isEmpty && fullName.isEmpty && id.isEmpty) return null;
|
||||||
|
return UserSummary(
|
||||||
|
username: username,
|
||||||
|
fullName: fullName,
|
||||||
|
userId: id,
|
||||||
|
email: '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final requesterJson = json['requester'] ??
|
||||||
|
json['requestor'] ??
|
||||||
|
json['sender'] ??
|
||||||
|
json['from_user'];
|
||||||
|
final addresseeJson = json['addressee'] ??
|
||||||
|
json['receiver'] ??
|
||||||
|
json['target_user'] ??
|
||||||
|
json['to_user'];
|
||||||
|
|
||||||
|
final requester = parseUser(
|
||||||
|
requesterJson,
|
||||||
|
usernameKey: 'requester_username',
|
||||||
|
fullKey: 'requester_full_name',
|
||||||
|
idKey: 'requester_id',
|
||||||
|
);
|
||||||
|
final addressee = parseUser(
|
||||||
|
addresseeJson,
|
||||||
|
usernameKey: 'addressee_username',
|
||||||
|
fullKey: 'addressee_full_name',
|
||||||
|
idKey: 'addressee_id',
|
||||||
|
);
|
||||||
|
|
||||||
|
final requesterUsername = _asString(json['requester_username']);
|
||||||
|
final requesterFullName = _asString(json['requester_full_name']);
|
||||||
|
final addresseeUsername = _asString(json['addressee_username']);
|
||||||
|
final addresseeFullName = _asString(json['addressee_full_name']);
|
||||||
|
|
||||||
|
final requesterId = requester?.userId.isNotEmpty == true
|
||||||
|
? requester!.userId
|
||||||
|
: pickId(json, [
|
||||||
|
'requester_id',
|
||||||
|
'requestor_id',
|
||||||
|
'requestorId',
|
||||||
|
'sender_id',
|
||||||
|
'from_user_id',
|
||||||
|
'from_id',
|
||||||
|
'user_id',
|
||||||
|
]);
|
||||||
|
final addresseeId = addressee?.userId.isNotEmpty == true
|
||||||
|
? addressee!.userId
|
||||||
|
: pickId(json, [
|
||||||
|
'addressee_id',
|
||||||
|
'addresseeId',
|
||||||
|
'receiver_id',
|
||||||
|
'target_user_id',
|
||||||
|
'to_user_id',
|
||||||
|
'to_id',
|
||||||
|
]);
|
||||||
|
|
||||||
|
final normalizedStatus = _asString(json['status'], 'none').trim();
|
||||||
|
|
||||||
|
return Friendship(
|
||||||
|
id: pickId(json, ['friendship_id', 'friendshipId', 'id']),
|
||||||
|
status: normalizedStatus.isNotEmpty ? normalizedStatus : 'none',
|
||||||
|
requesterId: requesterId,
|
||||||
|
addresseeId: addresseeId,
|
||||||
|
requester: requester ??
|
||||||
|
(requesterUsername.isNotEmpty || requesterFullName.isNotEmpty
|
||||||
|
? UserSummary(
|
||||||
|
username: requesterUsername,
|
||||||
|
fullName: requesterFullName,
|
||||||
|
userId: requesterId,
|
||||||
|
email: '',
|
||||||
|
)
|
||||||
|
: null),
|
||||||
|
addressee: addressee ??
|
||||||
|
(addresseeUsername.isNotEmpty || addresseeFullName.isNotEmpty
|
||||||
|
? UserSummary(
|
||||||
|
username: addresseeUsername,
|
||||||
|
fullName: addresseeFullName,
|
||||||
|
userId: addresseeId,
|
||||||
|
email: '',
|
||||||
|
)
|
||||||
|
: null),
|
||||||
|
requestedAt: parseDate(json['requested_at'] ?? json['requestedAt']),
|
||||||
|
respondedAt: parseDate(json['responded_at'] ?? json['respondedAt']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory Friendship.fromStatusJson(
|
||||||
|
Map<String, dynamic> json, {
|
||||||
|
String? targetUserId,
|
||||||
|
}) {
|
||||||
|
final statusVal = _asString(json['status'], 'none').trim();
|
||||||
|
if (statusVal.toLowerCase() == 'none') {
|
||||||
|
return Friendship(
|
||||||
|
id: null,
|
||||||
|
status: 'none',
|
||||||
|
requesterId: '',
|
||||||
|
addresseeId: targetUserId ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Friendship.fromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
Friendship copyWith({
|
||||||
|
String? id,
|
||||||
|
String? status,
|
||||||
|
String? requesterId,
|
||||||
|
String? addresseeId,
|
||||||
|
UserSummary? requester,
|
||||||
|
UserSummary? addressee,
|
||||||
|
DateTime? requestedAt,
|
||||||
|
DateTime? respondedAt,
|
||||||
|
}) {
|
||||||
|
return Friendship(
|
||||||
|
id: id ?? this.id,
|
||||||
|
status: status ?? this.status,
|
||||||
|
requesterId: requesterId ?? this.requesterId,
|
||||||
|
addresseeId: addresseeId ?? this.addresseeId,
|
||||||
|
requester: requester ?? this.requester,
|
||||||
|
addressee: addressee ?? this.addressee,
|
||||||
|
requestedAt: requestedAt ?? this.requestedAt,
|
||||||
|
respondedAt: respondedAt ?? this.respondedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class HomepageStats {
|
class HomepageStats {
|
||||||
final double totalMileage;
|
final double totalMileage;
|
||||||
final List<YearlyMileage> yearlyMileage;
|
final List<YearlyMileage> yearlyMileage;
|
||||||
@@ -441,6 +622,7 @@ class Loco {
|
|||||||
final String type, number, locoClass;
|
final String type, number, locoClass;
|
||||||
final String? name, operator, notes, evn;
|
final String? name, operator, notes, evn;
|
||||||
final bool powering;
|
final bool powering;
|
||||||
|
final int allocPos;
|
||||||
|
|
||||||
Loco({
|
Loco({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -452,6 +634,7 @@ class Loco {
|
|||||||
this.notes,
|
this.notes,
|
||||||
this.evn,
|
this.evn,
|
||||||
this.powering = true,
|
this.powering = true,
|
||||||
|
this.allocPos = 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory Loco.fromJson(Map<String, dynamic> json) => Loco(
|
factory Loco.fromJson(Map<String, dynamic> json) => Loco(
|
||||||
@@ -464,6 +647,7 @@ class Loco {
|
|||||||
notes: json['notes'],
|
notes: json['notes'],
|
||||||
evn: json['evn'],
|
evn: json['evn'],
|
||||||
powering: _asBool(json['alloc_powering'] ?? json['powering'], true),
|
powering: _asBool(json['alloc_powering'] ?? json['powering'], true),
|
||||||
|
allocPos: _asInt(json['alloc_pos'], 0),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,6 +681,7 @@ class LocoSummary extends Loco {
|
|||||||
this.location,
|
this.location,
|
||||||
Map<String, dynamic>? extra,
|
Map<String, dynamic>? extra,
|
||||||
super.powering = true,
|
super.powering = true,
|
||||||
|
super.allocPos = 0,
|
||||||
}) : extra = extra ?? const {},
|
}) : extra = extra ?? const {},
|
||||||
super(
|
super(
|
||||||
id: locoId,
|
id: locoId,
|
||||||
@@ -532,6 +717,7 @@ class LocoSummary extends Loco {
|
|||||||
location: json['location'],
|
location: json['location'],
|
||||||
extra: Map<String, dynamic>.from(json),
|
extra: Map<String, dynamic>.from(json),
|
||||||
powering: _asBool(json['alloc_powering'] ?? json['powering'], true),
|
powering: _asBool(json['alloc_powering'] ?? json['powering'], true),
|
||||||
|
allocPos: _asInt(json['alloc_pos'], 0),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -874,6 +1060,7 @@ class Leg {
|
|||||||
final String start, end, network, notes, headcode, user;
|
final String start, end, network, notes, headcode, user;
|
||||||
final String origin, destination;
|
final String origin, destination;
|
||||||
final List<String> route;
|
final List<String> route;
|
||||||
|
final String? legShareId;
|
||||||
final DateTime beginTime;
|
final DateTime beginTime;
|
||||||
final DateTime? endTime;
|
final DateTime? endTime;
|
||||||
final DateTime? originTime;
|
final DateTime? originTime;
|
||||||
@@ -904,6 +1091,7 @@ class Leg {
|
|||||||
this.endDelayMinutes,
|
this.endDelayMinutes,
|
||||||
this.origin = '',
|
this.origin = '',
|
||||||
this.destination = '',
|
this.destination = '',
|
||||||
|
this.legShareId,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory Leg.fromJson(Map<String, dynamic> json) {
|
factory Leg.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -944,6 +1132,93 @@ class Leg {
|
|||||||
: _asInt(json['leg_end_delay']),
|
: _asInt(json['leg_end_delay']),
|
||||||
origin: _asString(json['leg_origin']),
|
origin: _asString(json['leg_origin']),
|
||||||
destination: _asString(json['leg_destination']),
|
destination: _asString(json['leg_destination']),
|
||||||
|
legShareId: _asString(json['leg_share_id']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LegShareData {
|
||||||
|
final String id;
|
||||||
|
final Leg entry;
|
||||||
|
final Map<String, dynamic> metadata;
|
||||||
|
final UserSummary? requester;
|
||||||
|
final UserSummary? addressee;
|
||||||
|
final DateTime? sharedAt;
|
||||||
|
final String status;
|
||||||
|
final int? notificationId;
|
||||||
|
|
||||||
|
LegShareData({
|
||||||
|
required this.id,
|
||||||
|
required this.entry,
|
||||||
|
required this.metadata,
|
||||||
|
this.requester,
|
||||||
|
this.addressee,
|
||||||
|
this.sharedAt,
|
||||||
|
this.status = 'pending',
|
||||||
|
this.notificationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
LegShareData copyWith({int? notificationId}) {
|
||||||
|
return LegShareData(
|
||||||
|
id: id,
|
||||||
|
entry: entry,
|
||||||
|
metadata: metadata,
|
||||||
|
requester: requester,
|
||||||
|
addressee: addressee,
|
||||||
|
sharedAt: sharedAt,
|
||||||
|
status: status,
|
||||||
|
notificationId: notificationId ?? this.notificationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String get sharedFromName =>
|
||||||
|
requester?.displayName.isNotEmpty == true ? requester!.displayName : '';
|
||||||
|
|
||||||
|
factory LegShareData.fromJson(Map<String, dynamic> json) {
|
||||||
|
final metadataRaw = json['metadata'];
|
||||||
|
final entryRaw = json['entry'];
|
||||||
|
final metadata = metadataRaw is Map
|
||||||
|
? metadataRaw.map((k, v) => MapEntry(k.toString(), v))
|
||||||
|
: <String, dynamic>{};
|
||||||
|
final shareId = _asString(
|
||||||
|
metadata['leg_share_id'] ?? metadata['share_id'] ?? json['leg_share_id'],
|
||||||
|
);
|
||||||
|
final requester = UserSummary.fromJson({
|
||||||
|
"user_id": metadata['requester_id'] ?? metadata['from_user_id'] ?? '',
|
||||||
|
"username": metadata['requester_username'] ?? '',
|
||||||
|
"full_name": metadata['requester_full_name'] ?? '',
|
||||||
|
"email": '',
|
||||||
|
});
|
||||||
|
final addressee = UserSummary.fromJson({
|
||||||
|
"user_id": metadata['addressee_id'] ?? metadata['target_user_id'] ?? '',
|
||||||
|
"username": metadata['addressee_username'] ?? '',
|
||||||
|
"full_name": metadata['addressee_full_name'] ?? '',
|
||||||
|
"email": '',
|
||||||
|
});
|
||||||
|
DateTime? sharedAt;
|
||||||
|
for (final key in ['requested_at', 'created_at', 'shared_at']) {
|
||||||
|
final value = metadata[key];
|
||||||
|
if (value != null) {
|
||||||
|
sharedAt = DateTime.tryParse(value.toString());
|
||||||
|
if (sharedAt != null) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final entryMap = entryRaw is Map
|
||||||
|
? entryRaw.map((k, v) => MapEntry(k.toString(), v))
|
||||||
|
: json.map((k, v) => MapEntry(k.toString(), v));
|
||||||
|
final entryObj = Leg.fromJson(entryMap);
|
||||||
|
final resolvedId = shareId.isNotEmpty
|
||||||
|
? shareId
|
||||||
|
: entryObj.legShareId ?? _asString(json['leg_share_id']);
|
||||||
|
|
||||||
|
return LegShareData(
|
||||||
|
id: resolvedId,
|
||||||
|
entry: entryObj,
|
||||||
|
metadata: metadata,
|
||||||
|
requester: requester.userId.isEmpty ? null : requester,
|
||||||
|
addressee: addressee.userId.isEmpty ? null : addressee,
|
||||||
|
sharedAt: sharedAt,
|
||||||
|
status: _asString(metadata['status'], 'pending'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1223,6 +1498,7 @@ class UserNotification {
|
|||||||
final String title;
|
final String title;
|
||||||
final String body;
|
final String body;
|
||||||
final String channel;
|
final String channel;
|
||||||
|
final String type;
|
||||||
final DateTime? createdAt;
|
final DateTime? createdAt;
|
||||||
final bool dismissed;
|
final bool dismissed;
|
||||||
|
|
||||||
@@ -1231,6 +1507,7 @@ class UserNotification {
|
|||||||
required this.title,
|
required this.title,
|
||||||
required this.body,
|
required this.body,
|
||||||
required this.channel,
|
required this.channel,
|
||||||
|
required this.type,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
required this.dismissed,
|
required this.dismissed,
|
||||||
});
|
});
|
||||||
@@ -1248,6 +1525,7 @@ class UserNotification {
|
|||||||
title: _asString(json['title']),
|
title: _asString(json['title']),
|
||||||
body: _asString(json['body']),
|
body: _asString(json['body']),
|
||||||
channel: _asString(json['channel']),
|
channel: _asString(json['channel']),
|
||||||
|
type: _asString(json['type'] ?? json['notification_type']),
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
dismissed: _asBool(json['dismissed'] ?? false, false),
|
dismissed: _asBool(json['dismissed'] ?? false, false),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,3 +12,5 @@ part 'data_service_trips.dart';
|
|||||||
part 'data_service_notifications.dart';
|
part 'data_service_notifications.dart';
|
||||||
part 'data_service_badges.dart';
|
part 'data_service_badges.dart';
|
||||||
part 'data_service_stats.dart';
|
part 'data_service_stats.dart';
|
||||||
|
part 'data_service_friendships.dart';
|
||||||
|
part 'data_service_leg_share.dart';
|
||||||
|
|||||||
@@ -64,6 +64,18 @@ class DataService extends ChangeNotifier {
|
|||||||
bool isLocoTimelineLoading(int locoId) =>
|
bool isLocoTimelineLoading(int locoId) =>
|
||||||
_isLocoTimelineLoading[locoId] ?? false;
|
_isLocoTimelineLoading[locoId] ?? false;
|
||||||
|
|
||||||
|
// Friendships
|
||||||
|
List<Friendship> _friendships = [];
|
||||||
|
List<Friendship> _pendingIncoming = [];
|
||||||
|
List<Friendship> _pendingOutgoing = [];
|
||||||
|
List<Friendship> get friendships => _friendships;
|
||||||
|
List<Friendship> get pendingIncoming => _pendingIncoming;
|
||||||
|
List<Friendship> get pendingOutgoing => _pendingOutgoing;
|
||||||
|
bool _isFriendshipsLoading = false;
|
||||||
|
bool get isFriendshipsLoading => _isFriendshipsLoading;
|
||||||
|
bool _isPendingFriendshipsLoading = false;
|
||||||
|
bool get isPendingFriendshipsLoading => _isPendingFriendshipsLoading;
|
||||||
|
|
||||||
// Trips
|
// Trips
|
||||||
List<TripSummary> _trips = [];
|
List<TripSummary> _trips = [];
|
||||||
List<TripSummary> get trips => _trips;
|
List<TripSummary> get trips => _trips;
|
||||||
|
|||||||
282
lib/services/data_service/data_service_friendships.dart
Normal file
282
lib/services/data_service/data_service_friendships.dart
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
part of 'data_service.dart';
|
||||||
|
|
||||||
|
extension DataServiceFriendships on DataService {
|
||||||
|
Future<void> fetchFriendships() async {
|
||||||
|
_isFriendshipsLoading = true;
|
||||||
|
try {
|
||||||
|
final json = await api.get('/friendships');
|
||||||
|
List<dynamic>? list;
|
||||||
|
if (json is List) {
|
||||||
|
list = json;
|
||||||
|
} else if (json is Map) {
|
||||||
|
for (final key in ['friendships', 'data', 'items', 'results']) {
|
||||||
|
final value = json[key];
|
||||||
|
if (value is List) {
|
||||||
|
list = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final parsed = list
|
||||||
|
?.whereType<Map>()
|
||||||
|
.map((e) => Friendship.fromJson(
|
||||||
|
e.map((k, v) => MapEntry(k.toString(), v)),
|
||||||
|
))
|
||||||
|
.where((f) => f.isAccepted)
|
||||||
|
.toList();
|
||||||
|
_friendships = parsed ?? [];
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to fetch friendships: $e');
|
||||||
|
_friendships = [];
|
||||||
|
} finally {
|
||||||
|
_isFriendshipsLoading = false;
|
||||||
|
_notifyAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fetchPendingFriendships() async {
|
||||||
|
_isPendingFriendshipsLoading = true;
|
||||||
|
try {
|
||||||
|
final incoming = await _fetchPending('incoming');
|
||||||
|
final outgoing = await _fetchPending('outgoing');
|
||||||
|
_pendingIncoming = incoming;
|
||||||
|
_pendingOutgoing = outgoing;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to fetch pending friendships: $e');
|
||||||
|
_pendingIncoming = [];
|
||||||
|
_pendingOutgoing = [];
|
||||||
|
} finally {
|
||||||
|
_isPendingFriendshipsLoading = false;
|
||||||
|
_notifyAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Friendship>> _fetchPending(String direction) async {
|
||||||
|
final json = await api.get('/friendships/pending?direction=$direction');
|
||||||
|
List<dynamic>? list;
|
||||||
|
if (json is List) {
|
||||||
|
list = json;
|
||||||
|
} else if (json is Map) {
|
||||||
|
for (final key in ['friendships', 'data', 'items', 'results']) {
|
||||||
|
final value = json[key];
|
||||||
|
if (value is List) {
|
||||||
|
list = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
?.whereType<Map>()
|
||||||
|
.map((e) => Friendship.fromJson(
|
||||||
|
e.map((k, v) => MapEntry(k.toString(), v)),
|
||||||
|
))
|
||||||
|
.where((f) => f.isPending)
|
||||||
|
.toList() ??
|
||||||
|
const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Friendship?> fetchFriendshipById(String friendshipId) async {
|
||||||
|
try {
|
||||||
|
final json = await api.get('/friendships/$friendshipId');
|
||||||
|
if (json is Map) {
|
||||||
|
return Friendship.fromJson(
|
||||||
|
json.map((k, v) => MapEntry(k.toString(), v)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to fetch friendship $friendshipId: $e');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<UserSummary>> searchUsers(
|
||||||
|
String query, {
|
||||||
|
bool friendsOnly = false,
|
||||||
|
}) async {
|
||||||
|
final encoded = Uri.encodeComponent(query);
|
||||||
|
final json = await api.get(
|
||||||
|
'/users/search?search=$encoded${friendsOnly ? '&friends_only=true' : ''}',
|
||||||
|
);
|
||||||
|
List<dynamic>? list;
|
||||||
|
if (json is List) {
|
||||||
|
list = json;
|
||||||
|
} else if (json is Map) {
|
||||||
|
for (final key in ['users', 'data', 'results', 'items']) {
|
||||||
|
final value = json[key];
|
||||||
|
if (value is List) {
|
||||||
|
list = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (list == null) return const [];
|
||||||
|
return list
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((e) => UserSummary.fromJson(
|
||||||
|
e.map((k, v) => MapEntry(k.toString(), v)),
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Friendship> fetchFriendshipStatus(String targetUserId) async {
|
||||||
|
try {
|
||||||
|
final json = await api.get('/friendships/status/$targetUserId');
|
||||||
|
if (json is Map) {
|
||||||
|
return Friendship.fromStatusJson(
|
||||||
|
json.map((k, v) => MapEntry(k.toString(), v)),
|
||||||
|
targetUserId: targetUserId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to fetch friendship status for $targetUserId: $e');
|
||||||
|
}
|
||||||
|
return Friendship(
|
||||||
|
id: null,
|
||||||
|
status: 'none',
|
||||||
|
requesterId: '',
|
||||||
|
addresseeId: targetUserId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Friendship> requestFriendship(
|
||||||
|
String targetUserId, {
|
||||||
|
UserSummary? targetUser,
|
||||||
|
}) async {
|
||||||
|
final json = await api.post('/friendships/request', {
|
||||||
|
"target_user_id": targetUserId,
|
||||||
|
});
|
||||||
|
final friendship = _parseAndUpsertFriendship(
|
||||||
|
json,
|
||||||
|
fallbackStatus: 'pending',
|
||||||
|
overrideAddressee: targetUser,
|
||||||
|
);
|
||||||
|
_pendingOutgoing = [friendship, ..._pendingOutgoing];
|
||||||
|
_notifyAsync();
|
||||||
|
return friendship;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Friendship> acceptFriendship(String friendshipId) async {
|
||||||
|
final json = await api.post('/friendships/$friendshipId/accept', {});
|
||||||
|
final friendship = _parseAndUpsertFriendship(json, fallbackStatus: 'accepted');
|
||||||
|
_pendingIncoming = _pendingIncoming.where((f) => f.id != friendshipId).toList();
|
||||||
|
_notifyAsync();
|
||||||
|
return friendship;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Friendship> rejectFriendship(String friendshipId) async {
|
||||||
|
final json = await api.post('/friendships/$friendshipId/reject', {});
|
||||||
|
final friendship = _parseAndUpsertFriendship(json, fallbackStatus: 'declined');
|
||||||
|
_pendingIncoming = _pendingIncoming.where((f) => f.id != friendshipId).toList();
|
||||||
|
_notifyAsync();
|
||||||
|
return friendship;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Friendship> cancelFriendship(String friendshipId) async {
|
||||||
|
final json = await api.post('/friendships/$friendshipId/cancel', {});
|
||||||
|
final friendship =
|
||||||
|
_parseAndRemoveFriendship(json, friendshipId, status: 'none');
|
||||||
|
_pendingOutgoing =
|
||||||
|
_pendingOutgoing.where((f) => f.id != friendshipId).toList();
|
||||||
|
_notifyAsync();
|
||||||
|
return friendship;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteFriendship(String friendshipId) async {
|
||||||
|
try {
|
||||||
|
await api.delete('/friendships/$friendshipId');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to delete friendship $friendshipId: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
_friendships = _friendships.where((f) => f.id != friendshipId).toList();
|
||||||
|
_pendingIncoming =
|
||||||
|
_pendingIncoming.where((f) => f.id != friendshipId).toList();
|
||||||
|
_pendingOutgoing =
|
||||||
|
_pendingOutgoing.where((f) => f.id != friendshipId).toList();
|
||||||
|
_notifyAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Friendship> blockUser(String targetUserId) async {
|
||||||
|
final json = await api.post('/friendships/block', {
|
||||||
|
"target_user_id": targetUserId,
|
||||||
|
});
|
||||||
|
final friendship = _parseAndUpsertFriendship(json, fallbackStatus: 'blocked');
|
||||||
|
_pendingIncoming = _pendingIncoming
|
||||||
|
.where((f) => f.addresseeId != targetUserId && f.requesterId != targetUserId)
|
||||||
|
.toList();
|
||||||
|
_pendingOutgoing = _pendingOutgoing
|
||||||
|
.where((f) => f.addresseeId != targetUserId && f.requesterId != targetUserId)
|
||||||
|
.toList();
|
||||||
|
_notifyAsync();
|
||||||
|
return friendship;
|
||||||
|
}
|
||||||
|
|
||||||
|
Friendship _parseAndUpsertFriendship(
|
||||||
|
dynamic json, {
|
||||||
|
String fallbackStatus = 'none',
|
||||||
|
UserSummary? overrideAddressee,
|
||||||
|
}) {
|
||||||
|
Friendship friendship;
|
||||||
|
if (json is Map) {
|
||||||
|
friendship = Friendship.fromJson(
|
||||||
|
json.map((k, v) => MapEntry(k.toString(), v)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
friendship = Friendship(
|
||||||
|
id: null,
|
||||||
|
status: fallbackStatus,
|
||||||
|
requesterId: '',
|
||||||
|
addresseeId: '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (overrideAddressee != null &&
|
||||||
|
(friendship.addressee == null ||
|
||||||
|
friendship.addressee!.userId.isEmpty ||
|
||||||
|
friendship.addressee!.displayName.isEmpty)) {
|
||||||
|
friendship = friendship.copyWith(
|
||||||
|
addressee: overrideAddressee,
|
||||||
|
addresseeId: overrideAddressee.userId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_upsertFriendship(friendship);
|
||||||
|
return friendship;
|
||||||
|
}
|
||||||
|
|
||||||
|
Friendship _parseAndRemoveFriendship(
|
||||||
|
dynamic json,
|
||||||
|
String friendshipId, {
|
||||||
|
required String status,
|
||||||
|
}) {
|
||||||
|
Friendship friendship;
|
||||||
|
if (json is Map) {
|
||||||
|
friendship = Friendship.fromJson(
|
||||||
|
json.map((k, v) => MapEntry(k.toString(), v)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
friendship = Friendship(
|
||||||
|
id: friendshipId,
|
||||||
|
status: status,
|
||||||
|
requesterId: '',
|
||||||
|
addresseeId: '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_friendships = _friendships.where((f) => f.id != friendshipId).toList();
|
||||||
|
_notifyAsync();
|
||||||
|
return friendship;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _upsertFriendship(Friendship friendship) {
|
||||||
|
if (friendship.id == null || friendship.id!.isEmpty) {
|
||||||
|
_notifyAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final idx = _friendships.indexWhere((f) => f.id == friendship.id);
|
||||||
|
if (idx >= 0) {
|
||||||
|
_friendships[idx] = friendship;
|
||||||
|
} else {
|
||||||
|
_friendships = [friendship, ..._friendships];
|
||||||
|
}
|
||||||
|
_friendships = _friendships.where((f) => f.isAccepted).toList();
|
||||||
|
_notifyAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
97
lib/services/data_service/data_service_leg_share.dart
Normal file
97
lib/services/data_service/data_service_leg_share.dart
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
part of 'data_service.dart';
|
||||||
|
|
||||||
|
extension DataServiceLegShare on DataService {
|
||||||
|
Future<LegShareData?> fetchLegShare(String legShareId) async {
|
||||||
|
try {
|
||||||
|
final json = await api.get('/legs/share/$legShareId');
|
||||||
|
if (json is Map) {
|
||||||
|
return LegShareData.fromJson(
|
||||||
|
json.map((k, v) => MapEntry(k.toString(), v)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to fetch leg share $legShareId: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> rejectLegShare(String legShareId) async {
|
||||||
|
try {
|
||||||
|
await api.post('/legs/share/$legShareId/reject', {});
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to reject leg share $legShareId: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int?> acceptLegShare(LegShareData share) async {
|
||||||
|
final entry = share.entry;
|
||||||
|
final hasRoute = entry.route.isNotEmpty;
|
||||||
|
final locosPayload = <Map<String, dynamic>>[];
|
||||||
|
for (var i = 0; i < entry.locos.length; i++) {
|
||||||
|
final loco = entry.locos[i];
|
||||||
|
final pos = loco.allocPos != 0 ? loco.allocPos : i;
|
||||||
|
locosPayload.add({
|
||||||
|
"loco_id": loco.id,
|
||||||
|
"alloc_pos": pos,
|
||||||
|
"alloc_powering": loco.powering ? 1 : 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
final payload = <String, dynamic>{
|
||||||
|
"leg_share_id": share.id,
|
||||||
|
"leg_trip": null,
|
||||||
|
"leg_begin_time": entry.beginTime.toIso8601String(),
|
||||||
|
if (entry.endTime != null) "leg_end_time": entry.endTime!.toIso8601String(),
|
||||||
|
if (entry.originTime != null)
|
||||||
|
"leg_origin_time": entry.originTime!.toIso8601String(),
|
||||||
|
if (entry.destinationTime != null)
|
||||||
|
"leg_destination_time": entry.destinationTime!.toIso8601String(),
|
||||||
|
"leg_notes": entry.notes,
|
||||||
|
"leg_headcode": entry.headcode,
|
||||||
|
"leg_network": entry.network,
|
||||||
|
"leg_origin": entry.origin,
|
||||||
|
"leg_destination": entry.destination,
|
||||||
|
"leg_begin_delay": entry.beginDelayMinutes ?? 0,
|
||||||
|
if (entry.endDelayMinutes != null) "leg_end_delay": entry.endDelayMinutes,
|
||||||
|
"locos": locosPayload,
|
||||||
|
"share_user_ids": <String>[],
|
||||||
|
};
|
||||||
|
|
||||||
|
dynamic response;
|
||||||
|
if (hasRoute) {
|
||||||
|
response = await api.post(
|
||||||
|
'/add',
|
||||||
|
{
|
||||||
|
...payload,
|
||||||
|
"leg_route": entry.route,
|
||||||
|
"leg_mileage": entry.mileage,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
response = await api.post(
|
||||||
|
'/add/manual',
|
||||||
|
{
|
||||||
|
...payload,
|
||||||
|
"leg_start": entry.start,
|
||||||
|
"leg_end": entry.end,
|
||||||
|
"leg_distance": entry.mileage,
|
||||||
|
"isKilometers": false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final legId = _parseNullableInt(
|
||||||
|
response is Map ? response['leg_id'] : null,
|
||||||
|
);
|
||||||
|
if (legId != null) return legId;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _parseNullableInt(dynamic value) {
|
||||||
|
if (value is int) return value;
|
||||||
|
if (value is num) return value.toInt();
|
||||||
|
if (value is String) return int.tryParse(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,12 +13,16 @@ import 'package:mileograph_flutter/components/pages/loco_legs.dart';
|
|||||||
import 'package:mileograph_flutter/components/pages/loco_timeline.dart';
|
import 'package:mileograph_flutter/components/pages/loco_timeline.dart';
|
||||||
import 'package:mileograph_flutter/components/pages/logbook.dart';
|
import 'package:mileograph_flutter/components/pages/logbook.dart';
|
||||||
import 'package:mileograph_flutter/components/pages/more.dart';
|
import 'package:mileograph_flutter/components/pages/more.dart';
|
||||||
|
import 'package:mileograph_flutter/components/pages/badges.dart';
|
||||||
import 'package:mileograph_flutter/components/pages/new_entry.dart';
|
import 'package:mileograph_flutter/components/pages/new_entry.dart';
|
||||||
import 'package:mileograph_flutter/components/pages/new_traction.dart';
|
import 'package:mileograph_flutter/components/pages/new_traction.dart';
|
||||||
import 'package:mileograph_flutter/components/pages/profile.dart';
|
import 'package:mileograph_flutter/components/pages/profile.dart';
|
||||||
import 'package:mileograph_flutter/components/pages/settings.dart';
|
import 'package:mileograph_flutter/components/pages/settings.dart';
|
||||||
import 'package:mileograph_flutter/components/pages/stats.dart';
|
import 'package:mileograph_flutter/components/pages/stats.dart';
|
||||||
import 'package:mileograph_flutter/components/pages/traction.dart';
|
import 'package:mileograph_flutter/components/pages/traction.dart';
|
||||||
|
import 'package:mileograph_flutter/components/widgets/friend_request_notification_card.dart';
|
||||||
|
import 'package:mileograph_flutter/components/widgets/leg_share_notification_card.dart';
|
||||||
|
import 'package:mileograph_flutter/objects/objects.dart';
|
||||||
import 'package:mileograph_flutter/services/authservice.dart';
|
import 'package:mileograph_flutter/services/authservice.dart';
|
||||||
import 'package:mileograph_flutter/services/data_service.dart';
|
import 'package:mileograph_flutter/services/data_service.dart';
|
||||||
import 'package:mileograph_flutter/services/navigation_guard.dart';
|
import 'package:mileograph_flutter/services/navigation_guard.dart';
|
||||||
@@ -206,7 +210,15 @@ class _MyAppState extends State<MyApp> {
|
|||||||
path: '/traction/new',
|
path: '/traction/new',
|
||||||
builder: (context, state) => const NewTractionPage(),
|
builder: (context, state) => const NewTractionPage(),
|
||||||
),
|
),
|
||||||
GoRoute(path: '/add', builder: (context, state) => NewEntryPage()),
|
GoRoute(
|
||||||
|
path: '/add',
|
||||||
|
builder: (context, state) {
|
||||||
|
final extra = state.extra;
|
||||||
|
return NewEntryPage(
|
||||||
|
legShare: extra is LegShareData ? extra : null,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/more',
|
path: '/more',
|
||||||
builder: (context, state) => const MorePage(),
|
builder: (context, state) => const MorePage(),
|
||||||
@@ -215,6 +227,10 @@ class _MyAppState extends State<MyApp> {
|
|||||||
path: '/more/profile',
|
path: '/more/profile',
|
||||||
builder: (context, state) => const ProfilePage(),
|
builder: (context, state) => const ProfilePage(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/more/badges',
|
||||||
|
builder: (context, state) => const BadgesPage(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/more/stats',
|
path: '/more/stats',
|
||||||
builder: (context, state) => const StatsPage(),
|
builder: (context, state) => const StatsPage(),
|
||||||
@@ -350,6 +366,13 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
if (data.notifications.isEmpty) {
|
if (data.notifications.isEmpty) {
|
||||||
data.fetchNotifications();
|
data.fetchNotifications();
|
||||||
}
|
}
|
||||||
|
if (data.friendships.isEmpty && !data.isFriendshipsLoading) {
|
||||||
|
data.fetchFriendships();
|
||||||
|
}
|
||||||
|
if ((data.pendingIncoming.isEmpty && data.pendingOutgoing.isEmpty) &&
|
||||||
|
!data.isPendingFriendshipsLoading) {
|
||||||
|
data.fetchPendingFriendships();
|
||||||
|
}
|
||||||
_startNotificationPolling();
|
_startNotificationPolling();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -435,6 +458,11 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
_buildNotificationAction(context, data),
|
_buildNotificationAction(context, data),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Profile',
|
||||||
|
onPressed: () => context.go('/more/profile'),
|
||||||
|
icon: const Icon(Icons.person),
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Settings',
|
tooltip: 'Settings',
|
||||||
onPressed: () => context.go('/more/settings'),
|
onPressed: () => context.go('/more/settings'),
|
||||||
@@ -645,6 +673,13 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
Widget _buildNotificationsContent(BuildContext context, bool isWide) {
|
Widget _buildNotificationsContent(BuildContext context, bool isWide) {
|
||||||
final data = context.watch<DataService>();
|
final data = context.watch<DataService>();
|
||||||
final notifications = data.notifications;
|
final notifications = data.notifications;
|
||||||
|
final dismissibleIds = notifications
|
||||||
|
.where(
|
||||||
|
(n) =>
|
||||||
|
!_isFriendRequestNotification(n) && !_isLegShareNotification(n),
|
||||||
|
)
|
||||||
|
.map((e) => e.id)
|
||||||
|
.toList();
|
||||||
final loading = data.isNotificationsLoading;
|
final loading = data.isNotificationsLoading;
|
||||||
final listHeight = isWide
|
final listHeight = isWide
|
||||||
? 380.0
|
? 380.0
|
||||||
@@ -671,6 +706,9 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
separatorBuilder: (_, index) => const SizedBox(height: 8),
|
separatorBuilder: (_, index) => const SizedBox(height: 8),
|
||||||
itemBuilder: (ctx, index) {
|
itemBuilder: (ctx, index) {
|
||||||
final item = notifications[index];
|
final item = notifications[index];
|
||||||
|
final isFriendRequest = _isFriendRequestNotification(item);
|
||||||
|
final isLegShare = _isLegShareNotification(item);
|
||||||
|
final isSpecial = isFriendRequest || isLegShare;
|
||||||
return Card(
|
return Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(12.0),
|
padding: const EdgeInsets.all(12.0),
|
||||||
@@ -693,7 +731,11 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
item.body,
|
isFriendRequest || isLegShare
|
||||||
|
? isFriendRequest
|
||||||
|
? 'Accept to share entries'
|
||||||
|
: 'Shared entry details below.'
|
||||||
|
: item.body,
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
if (item.createdAt != null) ...[
|
if (item.createdAt != null) ...[
|
||||||
@@ -716,9 +758,10 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
if (!isSpecial) ...[
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
@@ -726,7 +769,20 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
child: const Text('Dismiss'),
|
child: const Text('Dismiss'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
],
|
||||||
|
),
|
||||||
|
if (isFriendRequest)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
|
child: FriendRequestNotificationCard(
|
||||||
|
notification: item,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isLegShare)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
|
child: LegShareNotificationCard(notification: item),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -756,10 +812,7 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
TextButton(
|
TextButton(
|
||||||
onPressed: notifications.isEmpty
|
onPressed: notifications.isEmpty
|
||||||
? null
|
? null
|
||||||
: () => _dismissNotifications(
|
: () => _dismissNotifications(context, dismissibleIds),
|
||||||
context,
|
|
||||||
notifications.map((e) => e.id).toList(),
|
|
||||||
),
|
|
||||||
child: const Text('Dismiss all'),
|
child: const Text('Dismiss all'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -794,6 +847,29 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
return '$y-$m-$d $hh:$mm';
|
return '$y-$m-$d $hh:$mm';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _isFriendRequestNotification(UserNotification notification) {
|
||||||
|
final type = notification.type.trim().toLowerCase();
|
||||||
|
final channel = notification.channel.trim().toLowerCase();
|
||||||
|
final title = notification.title.trim().toLowerCase();
|
||||||
|
bool matchesChannel =
|
||||||
|
channel == 'friend_request' || channel == 'friend-request';
|
||||||
|
if (!matchesChannel) {
|
||||||
|
matchesChannel = channel.contains('friend_request') ||
|
||||||
|
channel.contains('friend-request') ||
|
||||||
|
channel.contains('friend');
|
||||||
|
}
|
||||||
|
return matchesChannel ||
|
||||||
|
type == 'friend_request' ||
|
||||||
|
type == 'friend-request' ||
|
||||||
|
title.contains('friend request');
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isLegShareNotification(UserNotification notification) {
|
||||||
|
final channel = notification.channel.trim().toLowerCase();
|
||||||
|
final type = notification.type.trim().toLowerCase();
|
||||||
|
return channel.contains('leg_share') || type.contains('leg_share');
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildBadge(String label) {
|
Widget _buildBadge(String label) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 0.5.6+2
|
version: 0.6.0+3
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.8.1
|
sdk: ^3.8.1
|
||||||
|
|||||||
Reference in New Issue
Block a user