Enhance operations configuration and UI; add new functions for schedule upload and duty filtering

This commit is contained in:
ImBenji
2026-03-29 15:10:07 +01:00
parent 427bcadc77
commit 7049e58049
8 changed files with 1435 additions and 314 deletions
@@ -1,42 +1,850 @@
import "dart:async";
import "dart:math";
import "package:bus_running_record/models/channels/operations_channel.dart";
import "package:bus_running_record/models/operations/stop.dart";
import "package:bus_running_record/widgets/trip_diagram.dart";
import "package:flutter/foundation.dart";
import "package:go_router/go_router.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
class OperationsChannelView extends StatelessWidget {
class OperationsChannelView extends StatefulWidget {
const OperationsChannelView({required this.channel, super.key});
final OperationsChannel channel;
@override
State<OperationsChannelView> createState() => _OperationsChannelViewState();
}
class _OperationsChannelViewState extends State<OperationsChannelView> {
bool _loadingMeta = true;
bool _loadingDetail = false;
String? _scheduleId;
List<String> _duties = const [];
List<({String tripNumber, String dutyNumber})> _tripMeta = const [];
List<String> _stopNames = const [];
Map<String, ({String alias, String source})> _aliases = const {};
String filterBy = "By Duty";
Map<String, Object?> filterValue = {};
List<OperationsTripSnapshot>? _dutyTrips;
OperationsTripSnapshot? _tripSnapshot;
List<({OperationsTrip trip, String? time})>? _stopSchedule;
bool _stopHasMore = false;
bool _stopLoadingMore = false;
int _stopOffset = 0;
static const int _stopPageSize = 20;
List<GlobalKey> _stopRowKeys = [];
final ScrollController _stopScrollController = ScrollController();
@override
void initState() {
super.initState();
_stopScrollController.addListener(_onStopScroll);
unawaited(_loadMeta());
}
@override
void dispose() {
_stopScrollController.dispose();
super.dispose();
}
void _onStopScroll() {
if (!_stopHasMore || _stopLoadingMore || _loadingDetail) return;
final pos = _stopScrollController.position;
if (pos.pixels >= pos.maxScrollExtent - 100) {
unawaited(_loadMoreStop());
}
}
Future<dynamic> _invoke(String functionName, Map<String, dynamic> body) async {
final client = widget.channel.client;
client.functions.setAuth(
client.auth.currentSession?.accessToken ?? "",
);
return client.functions.invoke(
functionName,
body: body,
headers: {
"Authorization": "Bearer ${client.auth.currentSession?.accessToken ?? ""}",
},
);
}
Future<void> _loadMeta() async {
setState(() {
_loadingMeta = true;
});
try {
final response = await _invoke("operations-schedule-meta", {
"channel_id": widget.channel.id,
});
final data = response.data as Map?;
if (data == null) throw StateError("Empty response from meta function.");
if (data["has_schedule"] != true) {
if (!mounted) return;
setState(() {
_scheduleId = null;
_loadingMeta = false;
});
return;
}
final scheduleId = (data["schedule_id"] ?? "").toString();
final duties = List<String>.from(data["duties"] as List? ?? []);
final tripMetaRaw = data["trips"] as List? ?? [];
final tripMeta = tripMetaRaw.map((t) {
final m = t as Map;
return (
tripNumber: (m["trip_number"] ?? "").toString(),
dutyNumber: (m["duty_number"] ?? "").toString(),
);
}).toList();
final stopNames = List<String>.from(data["stop_names"] as List? ?? []);
final aliasesRaw = data["aliases"] as List? ?? [];
final aliases = <String, ({String alias, String source})>{};
for (final row in aliasesRaw) {
final m = row as Map;
final raw = (m["raw_stop_name"] ?? "").toString().trim();
final alias = (m["alias_stop_name"] ?? "").toString().trim();
final source = (m["source"] ?? "user").toString();
if (raw.isEmpty || alias.isEmpty) continue;
aliases[Stop.normalizeName(raw)] = (alias: alias, source: source);
}
final firstDuty = duties.isNotEmpty ? duties.first : null;
if (!mounted) return;
setState(() {
_scheduleId = scheduleId;
_duties = duties;
_tripMeta = tripMeta;
_stopNames = stopNames;
_aliases = aliases;
_loadingMeta = false;
if (firstDuty != null) {
filterBy = "By Duty";
filterValue["By Duty"] = firstDuty;
}
});
if (firstDuty != null) {
unawaited(_loadDutyDetail(firstDuty));
}
} catch (error, stackTrace) {
debugPrint("[OperationsChannelView] loadMeta failed: $error");
debugPrintStack(stackTrace: stackTrace);
if (!mounted) return;
setState(() => _loadingMeta = false);
}
}
Future<void> _loadDutyDetail(String dutyNumber) async {
final scheduleId = _scheduleId;
if (scheduleId == null) return;
setState(() {
_loadingDetail = true;
_dutyTrips = null;
});
try {
final response = await _invoke("operations-duty-detail", {
"channel_id": widget.channel.id,
"schedule_id": scheduleId,
"duty_number": dutyNumber,
});
final data = response.data as Map?;
final tripsRaw = data?["trips"] as List? ?? [];
final snapshots = <OperationsTripSnapshot>[];
for (final tripData in tripsRaw) {
final m = tripData as Map;
final trip = OperationsTrip(
id: (m["id"] ?? "").toString(),
scheduleId: scheduleId,
tripNumber: (m["trip_number"] ?? "").toString(),
dutyNumber: (m["duty_number"] ?? "").toString(),
busWorkNumber: (m["bus_work_number"] ?? "").toString(),
direction: (m["direction"] ?? "").toString(),
sortOrder: (m["sort_order"] as num?)?.toInt() ?? 0,
);
final stopsRaw = m["stops"] as List? ?? [];
final stops = stopsRaw.map((s) {
final sm = s as Map;
final name = (sm["stop_name"] ?? "").toString().trim();
final aliasEntry = _aliases[Stop.normalizeName(name)];
return OperationsScheduledStop(
id: (sm["id"] ?? "").toString(),
tripId: trip.id,
sequence: (sm["stop_sequence"] as num?)?.toInt() ?? 0,
name: name,
alias: aliasEntry?.alias,
aliasSource: aliasEntry?.source,
scheduledTime: (sm["scheduled_time"] ?? "").toString().trim().isEmpty
? null
: sm["scheduled_time"].toString(),
);
}).toList()
..sort((a, b) => a.sequence.compareTo(b.sequence));
snapshots.add(OperationsTripSnapshot(
trip: trip,
stops: stops.map((s) => OperationsStop(scheduledStop: s)).toList(),
scheduledStops: stops,
));
}
snapshots.sort((a, b) {
final aTime = a.scheduledStops.firstOrNull?.scheduledTime ?? "";
final bTime = b.scheduledStops.firstOrNull?.scheduledTime ?? "";
return aTime.compareTo(bTime);
});
if (!mounted) return;
setState(() {
_dutyTrips = snapshots;
_loadingDetail = false;
});
} catch (error, stackTrace) {
debugPrint("[OperationsChannelView] loadDutyDetail failed: $error");
debugPrintStack(stackTrace: stackTrace);
if (!mounted) return;
setState(() => _loadingDetail = false);
}
}
Future<void> _loadTripDetail(String tripNumber) async {
final scheduleId = _scheduleId;
if (scheduleId == null) return;
setState(() {
_loadingDetail = true;
_tripSnapshot = null;
});
try {
final response = await _invoke("operations-trip-detail", {
"channel_id": widget.channel.id,
"schedule_id": scheduleId,
"trip_number": tripNumber,
});
final data = response.data as Map?;
final tripData = data?["trip"] as Map?;
if (tripData == null) {
if (!mounted) return;
setState(() => _loadingDetail = false);
return;
}
final trip = OperationsTrip(
id: (tripData["id"] ?? "").toString(),
scheduleId: scheduleId,
tripNumber: (tripData["trip_number"] ?? "").toString(),
dutyNumber: (tripData["duty_number"] ?? "").toString(),
busWorkNumber: (tripData["bus_work_number"] ?? "").toString(),
direction: (tripData["direction"] ?? "").toString(),
sortOrder: (tripData["sort_order"] as num?)?.toInt() ?? 0,
);
final stopsRaw = data?["stops"] as List? ?? [];
final stops = stopsRaw.map((s) {
final sm = s as Map;
final name = (sm["stop_name"] ?? "").toString().trim();
final aliasEntry = _aliases[Stop.normalizeName(name)];
return OperationsScheduledStop(
id: (sm["id"] ?? "").toString(),
tripId: trip.id,
sequence: (sm["stop_sequence"] as num?)?.toInt() ?? 0,
name: name,
alias: aliasEntry?.alias,
aliasSource: aliasEntry?.source,
scheduledTime: (sm["scheduled_time"] ?? "").toString().trim().isEmpty
? null
: sm["scheduled_time"].toString(),
);
}).toList();
if (!mounted) return;
setState(() {
_tripSnapshot = OperationsTripSnapshot(
trip: trip,
stops: stops.map((s) => OperationsStop(scheduledStop: s)).toList(),
scheduledStops: stops,
);
_loadingDetail = false;
});
} catch (error, stackTrace) {
debugPrint("[OperationsChannelView] loadTripDetail failed: $error");
debugPrintStack(stackTrace: stackTrace);
if (!mounted) return;
setState(() => _loadingDetail = false);
}
}
Future<void> _loadStopDetail(String stopName) async {
final scheduleId = _scheduleId;
if (scheduleId == null) return;
setState(() {
_loadingDetail = true;
_stopSchedule = null;
_stopOffset = 0;
_stopHasMore = false;
});
try {
final response = await _invoke("operations-stop-detail", {
"channel_id": widget.channel.id,
"schedule_id": scheduleId,
"stop_name": stopName,
"offset": 0,
"limit": _stopPageSize,
});
final data = response.data as Map?;
final page = _parseStopSchedule(scheduleId, data?["schedule"] as List? ?? []);
final hasMore = data?["has_more"] == true;
if (!mounted) return;
setState(() {
_stopSchedule = page;
_stopOffset = page.length;
_stopHasMore = hasMore;
_stopRowKeys = List.generate(page.length, (_) => GlobalKey());
_loadingDetail = false;
});
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToClosestTime(page));
} catch (error, stackTrace) {
debugPrint("[OperationsChannelView] loadStopDetail failed: $error");
debugPrintStack(stackTrace: stackTrace);
if (!mounted) return;
setState(() => _loadingDetail = false);
}
}
Future<void> _loadMoreStop() async {
final scheduleId = _scheduleId;
if (scheduleId == null || !_stopHasMore || _stopLoadingMore) return;
final stopName = (filterValue["By Stop"] ?? "").toString().trim();
if (stopName.isEmpty) return;
setState(() => _stopLoadingMore = true);
try {
final response = await _invoke("operations-stop-detail", {
"channel_id": widget.channel.id,
"schedule_id": scheduleId,
"stop_name": stopName,
"offset": _stopOffset,
"limit": _stopPageSize,
});
final data = response.data as Map?;
final page = _parseStopSchedule(scheduleId, data?["schedule"] as List? ?? []);
final hasMore = data?["has_more"] == true;
if (!mounted) return;
setState(() {
_stopSchedule = [...?_stopSchedule, ...page];
_stopOffset += page.length;
_stopHasMore = hasMore;
_stopLoadingMore = false;
});
} catch (error, stackTrace) {
debugPrint("[OperationsChannelView] loadMoreStop failed: $error");
debugPrintStack(stackTrace: stackTrace);
if (!mounted) return;
setState(() => _stopLoadingMore = false);
}
}
void _scrollToClosestTime(List<({OperationsTrip trip, String? time})> schedule) {
if (!_stopScrollController.hasClients) return;
if (schedule.isEmpty) return;
final now = DateTime.now();
final nowMins = now.hour * 60 + now.minute;
int bestIndex = 0;
int bestDiff = 9999;
for (var i = 0; i < schedule.length; i++) {
final time = schedule[i].time;
if (time == null) continue;
final parts = time.split(":");
if (parts.length < 2) continue;
final h = int.tryParse(parts[0]);
final m = int.tryParse(parts[1]);
if (h == null || m == null) continue;
final diff = ((h * 60 + m) - nowMins).abs();
if (diff < bestDiff) {
bestDiff = diff;
bestIndex = i;
}
}
// each row is roughly 61px tall (10 vertical padding top+bottom + ~41 content)
const rowHeight = 61.0;
final offset = (bestIndex * rowHeight).clamp(
0.0,
_stopScrollController.position.maxScrollExtent,
);
_stopScrollController.animateTo(
offset,
duration: const Duration(milliseconds: 350),
curve: Curves.easeOut,
);
}
List<({OperationsTrip trip, String? time})> _parseStopSchedule(
String scheduleId,
List scheduleRaw,
) {
return scheduleRaw.map((row) {
final m = row as Map;
final tripData = m["trip"] as Map;
final trip = OperationsTrip(
id: (tripData["id"] ?? "").toString(),
scheduleId: scheduleId,
tripNumber: (tripData["trip_number"] ?? "").toString(),
dutyNumber: (tripData["duty_number"] ?? "").toString(),
busWorkNumber: (tripData["bus_work_number"] ?? "").toString(),
direction: (tripData["direction"] ?? "").toString(),
sortOrder: 0,
);
final time = (m["scheduled_time"] ?? "").toString().trim();
return (trip: trip, time: time.isEmpty ? null : time);
}).toList();
}
String? _aliasForStop(String rawName) =>
_aliases[Stop.normalizeName(rawName)]?.alias;
Widget _stopLabel(String rawName) {
final alias = _aliasForStop(rawName);
if (alias == null || alias.isEmpty) return Text(rawName);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(alias).semiBold,
if (alias != rawName)
Text(
rawName,
style: const TextStyle(fontSize: 11, height: 1),
).xSmall.muted.semiBold,
],
);
}
List<TripDiagramEntry> _diagramEntries(List<OperationsScheduledStop> stops) {
return stops.map((stop) => TripDiagramEntry(
label: stop.displayName,
labelIcon: stop.aliasSource == "ai" ? LucideIcons.sparkles : null,
subtitle: stop.displayName == stop.name ? null : stop.name,
time: stop.scheduledTime,
)).toList(growable: false);
}
@override
Widget build(BuildContext context) {
double bottomPadding = MediaQuery.of(context).padding.bottom;
bool isMobile = defaultTargetPlatform == TargetPlatform.iOS ||
defaultTargetPlatform == TargetPlatform.android;
final isScheduleUploaded = channel.id.isEmpty;
if (_loadingMeta) {
return const Center(child: CircularProgressIndicator());
}
if (!isScheduleUploaded) {
if (_scheduleId == null) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
"No schedule uploaded yet.",
).h4,
Text("No schedule uploaded yet.").h4,
Gap(8),
Button.secondary(
child: Text(
"Upload Schedule",
onPressed: () => context.go(
"/channel/${widget.channel.organizationId}/${widget.channel.id}/upload",
),
onPressed: () {
context.go(
"/channel/${channel.organizationId}/${channel.id}/upload",
);
},
)
child: const Text("Upload Schedule"),
),
],
)
),
);
}
return const SizedBox.expand();
return Column(
children: [
Expanded(
child: Builder(
builder: (context) {
if (_loadingDetail) {
return const Center(child: CircularProgressIndicator());
}
// By Stop
if (filterBy == "By Stop") {
final selectedStop = (filterValue["By Stop"] ?? "").toString().trim();
if (selectedStop.isEmpty) {
return Center(child: Text("Select a stop to preview its schedule.").small.muted);
}
final schedules = _stopSchedule ?? const [];
return Column(
children: [
Gap(8),
Divider(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Icon(LucideIcons.pin),
Gap(8),
_stopLabel(selectedStop),
],
),
),
Divider(),
Gap(2),
Divider(),
Expanded(
child: SingleChildScrollView(
controller: _stopScrollController,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (schedules.isEmpty)
Padding(
padding: const EdgeInsets.all(16),
child: Text("No scheduled times found for this stop.").small.muted,
)
else
...schedules.map(
(row) => Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Colors.white.withValues(alpha: 0.07),
),
),
),
child: Row(
children: [
Gap(16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text("${row.trip.dutyNumber}").extraBold,
Text(
"Trip ${row.trip.tripNumber} • Bus ${row.trip.busWorkNumber}",
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
),
).muted,
],
),
Row(
children: [
Text(row.trip.direction).muted.textSmall,
if (row.trip.direction.toLowerCase().contains("out")) ...[
Gap(4),
Icon(LucideIcons.arrowUpRight).iconSmall,
] else if (row.trip.direction.toLowerCase().contains("in")) ...[
Gap(4),
Icon(LucideIcons.arrowDownLeft).iconSmall,
],
],
),
],
),
),
Text(row.time ?? "--:--").extraBold.mono,
Gap(2),
Icon(LucideIcons.chevronRight),
Gap(2),
],
),
),
),
if (_stopLoadingMore)
const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
),
],
),
),
),
],
);
}
// By Duty
if (filterBy == "By Duty") {
final selectedDuty = (filterValue["By Duty"] ?? "").toString();
if (selectedDuty.isEmpty) {
return Center(child: Text("Select a duty to preview.").small.muted);
}
final snapshots = _dutyTrips ?? const [];
if (snapshots.isEmpty) {
return Center(child: Text("No trips found for this duty.").small.muted);
}
return Column(
children: [
Gap(8),
Divider(),
Container(
height: 50,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Icon(LucideIcons.busFront),
Gap(10),
Text("Duty $selectedDuty".toUpperCase()).extraBold.textSmall,
],
),
),
Divider(),
Gap(2),
Divider(),
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < snapshots.length; i++) ...[
if (i != 0) ...[
Divider(),
Gap(2),
Divider(),
],
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Icon(LucideIcons.bus),
Gap(8),
Text(
"Trip ${snapshots[i].trip.tripNumber} • Duty ${snapshots[i].trip.dutyNumber}",
).semiBold.textSmall,
],
),
),
Divider(),
TripDiagram(
lineColor: Colors.red,
entries: _diagramEntries(snapshots[i].scheduledStops),
leftOffset: 12,
rowHeight: 48,
),
],
],
),
),
),
],
);
}
// By Trip
if (filterBy == "By Trip") {
final selectedTripNumber = (filterValue["By Trip"] ?? "").toString();
if (selectedTripNumber.isEmpty) {
return Center(child: Text("Select a trip to preview.").small.muted);
}
final snapshot = _tripSnapshot;
if (snapshot == null) {
return Center(child: Text("No data for this trip.").small.muted);
}
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Gap(8),
Divider(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Icon(LucideIcons.bus),
Gap(8),
Text(
"Trip ${snapshot.trip.tripNumber} • Duty ${snapshot.trip.dutyNumber}",
).semiBold.textSmall,
],
),
),
Divider(),
TripDiagram(
lineColor: Colors.red,
entries: _diagramEntries(snapshot.scheduledStops),
leftOffset: 12,
rowHeight: 48,
),
],
),
);
}
return Center(
child: Text("Select a filter to preview the schedule.").small.muted,
);
},
),
),
Divider(),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
SizedBox(
width: double.infinity,
height: 50,
child: Select<String>(
itemBuilder: (context, item) => Text("Filter by: $item"),
popupConstraints: BoxConstraints(
maxHeight: 300,
maxWidth: !isMobile ? 200 : double.infinity,
),
onChanged: (value) {
setState(() {
filterBy = value ?? filterBy;
});
},
value: filterBy,
popup: SelectPopup(
items: SelectItemList(
children: [
SelectItemButton(value: "By Duty", child: Text("By Duty")),
SelectItemButton(value: "By Trip", child: Text("By Trip")),
SelectItemButton(value: "By Stop", child: Text("By Stop")),
],
),
),
),
),
Gap(16),
SizedBox(
width: double.infinity,
height: 50,
child: Select<Object>(
itemBuilder: (context, item) {
if (filterBy == "By Duty") return Text("Duty $item");
if (filterBy == "By Trip") {
final tripNumber = item.toString();
final match = _tripMeta.where((t) => t.tripNumber == tripNumber).firstOrNull;
if (match == null) return Text("Trip $tripNumber");
return Text("Trip ${match.tripNumber} • Duty ${match.dutyNumber}");
}
if (filterBy == "By Stop") return _stopLabel(item.toString());
return Text("Undefined");
},
popupConstraints: BoxConstraints(
maxHeight: 300,
maxWidth: !isMobile ? 200 : double.infinity,
),
onChanged: (value) {
if (value == null) return;
setState(() {
filterValue[filterBy] = value;
_dutyTrips = null;
_tripSnapshot = null;
_stopSchedule = null;
});
if (filterBy == "By Duty") {
unawaited(_loadDutyDetail(value.toString()));
} else if (filterBy == "By Trip") {
unawaited(_loadTripDetail(value.toString()));
} else if (filterBy == "By Stop") {
unawaited(_loadStopDetail(value.toString()));
}
},
value: filterValue[filterBy],
popup: SelectPopup.builder(
searchPlaceholder: Text("Search ${filterBy.toLowerCase()}"),
builder: (context, searchQuery) {
List<SelectItemButton> items = [];
if (filterBy == "By Duty") {
items = _duties.map((d) => SelectItemButton(
value: d,
child: Text(d),
)).toList();
} else if (filterBy == "By Trip") {
final trips = List.from(_tripMeta);
trips.sort((a, b) {
final aNum = int.tryParse(a.tripNumber);
final bNum = int.tryParse(b.tripNumber);
if (aNum != null && bNum != null) return aNum.compareTo(bNum);
return a.tripNumber.compareTo(b.tripNumber);
});
items = trips.map((t) => SelectItemButton(
value: t.tripNumber,
child: Text("Trip ${t.tripNumber}"),
)).toList();
} else if (filterBy == "By Stop") {
items = _stopNames.map((s) => SelectItemButton(
value: s,
child: _stopLabel(s),
)).toList();
}
return SelectItemList(children: items);
},
),
),
),
],
),
),
Gap(max(bottomPadding, 16)),
],
);
}
}
+111 -105
View File
@@ -26,71 +26,71 @@ class HomeLeftSidebar extends StatelessWidget {
final displayName = user?.email ?? "Signed in user";
final initials = _buildInitials(displayName);
return GestureDetector(
behavior: HitTestBehavior.opaque,
child: Row(
children: [
SizedBox(
width: 70,
child: Column(
children: [
Expanded(
child: _ServerRail(
organizations: organizations,
selectedOrganizationId: collab.selectedOrganizationId,
double topPadding = MediaQuery.of(context).padding.top;
double bottomPadding = MediaQuery.of(context).padding.bottom;
return Row(
children: [
SizedBox(
width: 70,
child: Column(
children: [
Expanded(
child: _ServerRail(
organizations: organizations,
selectedOrganizationId: collab.selectedOrganizationId,
),
),
Container(
padding: const EdgeInsets.all(8.0),
width: 100,
child: AspectRatio(
aspectRatio: 1,
child: IconButton.outline(
onPressed: () {
unawaited(showCreateOrganizationDialog(context));
},
shape: ButtonShape.circle,
size: ButtonSize.normal,
icon: const Icon(LucideIcons.plus),
),
),
Container(
padding: const EdgeInsets.all(8.0),
width: 100,
child: AspectRatio(
aspectRatio: 1,
child: IconButton.outline(
onPressed: () {
unawaited(showCreateOrganizationDialog(context));
},
shape: ButtonShape.circle,
size: ButtonSize.normal,
icon: const Icon(LucideIcons.plus),
),
),
const Divider(),
RotatedBox(
quarterTurns: 3,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"ROADBOUND",
style: const TextStyle(height: 1),
).extraBold.x3Large,
Text(
"by IMBENJI.NET LTD",
style: const TextStyle(height: 1),
).small.muted,
],
),
),
const Divider(),
RotatedBox(
quarterTurns: 3,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"ROADBOUND",
style: const TextStyle(height: 1),
).extraBold.x3Large,
Text(
"by IMBENJI.NET LTD",
style: const TextStyle(height: 1),
).small.muted,
],
),
),
),
],
),
),
Gap(bottomPadding)
],
),
const VerticalDivider(),
Container(
),
const VerticalDivider(),
Expanded(
child: Container(
color: Theme.of(context).colorScheme.background,
width: 300,
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width - 50,
),
child: IntrinsicWidth(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Gap(topPadding),
Padding(
padding: const EdgeInsets.all(8),
child: SizedBox(
@@ -178,8 +178,8 @@ class HomeLeftSidebar extends StatelessWidget {
),
),
),
],
),
),
],
);
}
}
@@ -301,62 +301,68 @@ class _ServerRail extends StatelessWidget {
return const SizedBox.shrink();
}
double topPadding = MediaQuery.of(context).padding.top;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Column(
children: organizations.map((organization) {
final isSelected = selectedOrganizationId == organization.id;
final fallbackInitial = _fallbackInitial(organization.name);
final buttonStyle = _organizationButtonStyle(isSelected);
children: [
Gap(topPadding),
return Padding(
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: HoverCard(
hoverBuilder: (context) {
return SurfaceCard(
child: Basic(title: Text(organization.name).medium),
);
},
anchorAlignment: Alignment.centerLeft,
popoverAlignment: Alignment.centerRight,
popoverOffset: const Offset(-4, 0),
child: ContextMenu(
items: [
MenuLabel(child: Text(organization.name)),
const MenuDivider(),
MenuButton(
leading: const Icon(LucideIcons.settings2).iconSmall,
onPressed: (_) {
_openOrganizationSettings(context, organization.id);
},
child: const Text("Server Settings"),
),
],
child: Stack(
children: [
_buildOrganizationAvatar(
organization: organization,
fallbackInitial: fallbackInitial,
),
SizedBox(
width: 54,
height: 54,
child: Button(
style: buttonStyle,
onPressed: () {
unawaited(
_openOrganization(context, organization.id),
);
},
child: Container(),
),
...organizations.map((organization) {
final isSelected = selectedOrganizationId == organization.id;
final fallbackInitial = _fallbackInitial(organization.name);
final buttonStyle = _organizationButtonStyle(isSelected);
return Padding(
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: HoverCard(
hoverBuilder: (context) {
return SurfaceCard(
child: Basic(title: Text(organization.name).medium),
);
},
anchorAlignment: Alignment.centerLeft,
popoverAlignment: Alignment.centerRight,
popoverOffset: const Offset(-4, 0),
child: ContextMenu(
items: [
MenuLabel(child: Text(organization.name)),
const MenuDivider(),
MenuButton(
leading: const Icon(LucideIcons.settings2).iconSmall,
onPressed: (_) {
_openOrganizationSettings(context, organization.id);
},
child: const Text("Server Settings"),
),
],
child: Stack(
children: [
_buildOrganizationAvatar(
organization: organization,
fallbackInitial: fallbackInitial,
),
SizedBox(
width: 54,
height: 54,
child: Button(
style: buttonStyle,
onPressed: () {
unawaited(
_openOrganization(context, organization.id),
);
},
child: Container(),
),
),
],
),
),
),
),
);
}).toList(),
);
})
],
),
);
}
+43 -100
View File
@@ -1,126 +1,69 @@
import "dart:math" as math;
import "package:flutter/material.dart";
import 'package:shadcn_flutter/shadcn_flutter.dart';
class SidebarSwiper extends StatefulWidget {
const SidebarSwiper({
required this.sidebar,
required this.child,
this.maxSidebarWidth = 360,
this.sidebarWidthFactor = 0.92,
this.edgeDragWidth = 44,
this.animationDuration = const Duration(milliseconds: 220),
super.key,
});
final Widget sidebar;
final Widget child;
final double maxSidebarWidth;
final double sidebarWidthFactor;
final double edgeDragWidth;
final Duration animationDuration;
@override
State<SidebarSwiper> createState() => _SidebarSwiperState();
}
class _SidebarSwiperState extends State<SidebarSwiper> {
static const double _closedExtraOffset = 12;
final _controller = PageController(initialPage: 1);
double _progress = 0; // 0 = closed, 1 = fully open
bool _isDragging = false;
bool _canDrag = false;
double _dragStartGlobalX = 0;
double _dragStartProgress = 0;
void _setOpen(bool value) {
setState(() {
_isDragging = false;
_progress = value ? 1 : 0;
});
}
void _handleDragStart(DragStartDetails details, double sidebarWidth) {
final isOpen = _progress > 0.001;
final fromEdge = details.globalPosition.dx <= widget.edgeDragWidth;
final fromSidebarZone = details.globalPosition.dx <= sidebarWidth;
_canDrag = fromEdge || (isOpen && fromSidebarZone);
if (!_canDrag) return;
setState(() {
_isDragging = true;
_dragStartGlobalX = details.globalPosition.dx;
_dragStartProgress = _progress;
});
}
void _handleDragUpdate(DragUpdateDetails details, double sidebarWidth) {
if (!_canDrag || !_isDragging) return;
if (sidebarWidth <= 0) return;
final movedX = details.globalPosition.dx - _dragStartGlobalX;
setState(() {
_progress = (_dragStartProgress + (movedX / sidebarWidth)).clamp(0, 1);
});
}
void _handleDragEnd(DragEndDetails details) {
if (!_canDrag) return;
_canDrag = false;
final velocity = details.primaryVelocity ?? 0;
final shouldOpen = velocity > 250
? true
: (velocity < -250 ? false : _progress >= 0.35);
_setOpen(shouldOpen);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final sidebarWidth = math.min(
widget.maxSidebarWidth,
screenWidth * widget.sidebarWidthFactor,
);
final leftOffset =
-(sidebarWidth + _closedExtraOffset) +
((sidebarWidth + _closedExtraOffset) * _progress);
final showScrim = _progress > 0;
return Scaffold(
child: PageView(
controller: _controller,
physics: const ClampingScrollPhysics(),
children: [
// page 0 - sidebar
Row(
children: [
Expanded(child: _KeepAlive(child: Scaffold(child: widget.sidebar))),
const VerticalDivider(width: 1),
],
),
return Stack(
children: [
widget.child,
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onHorizontalDragStart: (details) =>
_handleDragStart(details, sidebarWidth),
onHorizontalDragUpdate: (details) =>
_handleDragUpdate(details, sidebarWidth),
onHorizontalDragEnd: _handleDragEnd,
child: const SizedBox.expand(),
),
),
if (showScrim)
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _setOpen(false),
child: Container(
color: Colors.black.withValues(alpha: 0.45 * _progress),
),
),
),
AnimatedPositioned(
duration: _isDragging ? Duration.zero : widget.animationDuration,
curve: Curves.easeOutCubic,
left: leftOffset,
top: 0,
bottom: 0,
width: sidebarWidth,
child: Material(
color: Theme.of(context).scaffoldBackgroundColor,
child: widget.sidebar,
),
),
],
// page 1 - main content
_KeepAlive(child: widget.child),
],
),
);
}
}
class _KeepAlive extends StatefulWidget {
const _KeepAlive({required this.child});
final Widget child;
@override
State<_KeepAlive> createState() => _KeepAliveState();
}
class _KeepAliveState extends State<_KeepAlive>
with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
return SizedBox.expand(child: widget.child);
}
}