feat(mal): added remove button, rewatching support, and rich profile dashboard

This commit is contained in:
paregi12 2026-03-12 16:13:32 +05:30
parent 9122ff5345
commit c9fd5438b4
7 changed files with 392 additions and 86 deletions

View file

@ -10,6 +10,8 @@ import {
KeyboardAvoidingView,
Platform,
ActivityIndicator,
Alert,
Switch,
} from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { useTheme } from '../../contexts/ThemeContext';
@ -36,13 +38,16 @@ export const MalEditModal: React.FC<MalEditModalProps> = ({
const [status, setStatus] = useState<MalListStatus>(anime.list_status.status);
const [episodes, setEpisodes] = useState(anime.list_status.num_episodes_watched.toString());
const [score, setScore] = useState(anime.list_status.score.toString());
const [isRewatching, setIsRewatching] = useState(anime.list_status.is_rewatching || false);
const [isUpdating, setIsUpdating] = useState(false);
const [isRemoving, setIsRemoving] = useState(false);
useEffect(() => {
if (visible) {
setStatus(anime.list_status.status);
setEpisodes(anime.list_status.num_episodes_watched.toString());
setScore(anime.list_status.score.toString());
setIsRewatching(anime.list_status.is_rewatching || false);
}
}, [visible, anime]);
@ -55,7 +60,7 @@ export const MalEditModal: React.FC<MalEditModalProps> = ({
// Validation: MAL scores must be between 0 and 10
scoreNum = Math.max(0, Math.min(10, scoreNum));
await MalApiService.updateStatus(anime.node.id, status, epNum, scoreNum);
await MalApiService.updateStatus(anime.node.id, status, epNum, scoreNum, isRewatching);
showSuccess('Updated', `${anime.node.title} status updated on MAL`);
onUpdateSuccess();
@ -67,6 +72,33 @@ export const MalEditModal: React.FC<MalEditModalProps> = ({
}
};
const handleRemove = async () => {
Alert.alert(
'Remove from List',
`Are you sure you want to remove ${anime.node.title} from your MyAnimeList?`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Remove',
style: 'destructive',
onPress: async () => {
setIsRemoving(true);
try {
await MalApiService.removeFromList(anime.node.id);
showSuccess('Removed', `${anime.node.title} removed from MAL`);
onUpdateSuccess();
onClose();
} catch (error) {
showError('Remove Failed', 'Could not remove from MAL');
} finally {
setIsRemoving(false);
}
}
}
]
);
};
const statusOptions: { label: string; value: MalListStatus }[] = [
{ label: 'Watching', value: 'watching' },
{ label: 'Completed', value: 'completed' },
@ -160,10 +192,25 @@ export const MalEditModal: React.FC<MalEditModalProps> = ({
</View>
</View>
<View style={styles.rewatchRow}>
<View style={styles.rewatchTextContainer}>
<Text style={[styles.label, { color: currentTheme.colors.highEmphasis, marginTop: 0 }]}>Rewatching</Text>
<Text style={[styles.rewatchDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Mark this if you are watching the series again.
</Text>
</View>
<Switch
value={isRewatching}
onValueChange={setIsRewatching}
trackColor={{ false: currentTheme.colors.border, true: currentTheme.colors.primary + '80' }}
thumbColor={isRewatching ? currentTheme.colors.primary : '#f4f3f4'}
/>
</View>
<TouchableOpacity
style={[styles.updateButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={handleUpdate}
disabled={isUpdating}
disabled={isUpdating || isRemoving}
>
{isUpdating ? (
<ActivityIndicator color="white" />
@ -171,6 +218,20 @@ export const MalEditModal: React.FC<MalEditModalProps> = ({
<Text style={styles.updateButtonText}>Update MAL</Text>
)}
</TouchableOpacity>
<TouchableOpacity
style={[styles.removeButton, { borderColor: currentTheme.colors.error || '#FF5252' }]}
onPress={handleRemove}
disabled={isUpdating || isRemoving}
>
{isRemoving ? (
<ActivityIndicator color={currentTheme.colors.error || '#FF5252'} />
) : (
<Text style={[styles.removeButtonText, { color: currentTheme.colors.error || '#FF5252' }]}>
Remove from List
</Text>
)}
</TouchableOpacity>
</ScrollView>
</View>
</KeyboardAvoidingView>
@ -245,6 +306,21 @@ const styles = StyleSheet.create({
paddingHorizontal: 12,
fontSize: 16,
},
rewatchRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 20,
paddingVertical: 8,
},
rewatchTextContainer: {
flex: 1,
marginRight: 16,
},
rewatchDescription: {
fontSize: 12,
marginTop: 2,
},
updateButton: {
height: 48,
borderRadius: 24,
@ -258,4 +334,17 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '700',
},
removeButton: {
height: 48,
borderRadius: 24,
justifyContent: 'center',
alignItems: 'center',
marginTop: 8,
marginBottom: 20,
borderWidth: 1,
},
removeButtonText: {
fontSize: 16,
fontWeight: '600',
},
});

View file

@ -1487,7 +1487,7 @@ const LibraryScreen = () => {
const currentOffset = isLoadMore ? malOffset : 0;
setMalLoading(true);
try {
const response = await MalApiService.getUserList(undefined, currentOffset);
const response = await MalApiService.getUserList(undefined, currentOffset, 100);
if (isLoadMore) {
setMalMalList(prev => [...prev, ...response.data]);
} else {

View file

@ -187,61 +187,68 @@ const MalLibraryScreen: React.FC = () => {
};
return (
<SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" />
<View style={styles.header}>
<TouchableOpacity onPress={() => navigation.goBack()} style={styles.backButton}>
<MaterialIcons name="arrow-back" size={24} color={currentTheme.colors.highEmphasis} />
</TouchableOpacity>
<Text style={[styles.headerTitle, { color: currentTheme.colors.highEmphasis }]}>
MyAnimeList
</Text>
{/* Requirement 6: Manual Sync Button */}
<TouchableOpacity onPress={handleRefresh} style={styles.syncButton} disabled={isLoading}>
{isLoading ? (
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
) : (
<MaterialIcons name="sync" size={24} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
</View>
{!isLoading || isRefreshing ? (
<ScrollView
refreshControl={
<RefreshControl refreshing={isRefreshing} onRefresh={handleRefresh} tintColor={currentTheme.colors.primary} />
}
contentContainerStyle={{ paddingBottom: 40 }}
>
{renderSection('watching', 'Watching', 'play-circle-outline')}
{renderSection('plan_to_watch', 'Plan to Watch', 'bookmark-outline')}
{renderSection('completed', 'Completed', 'check-circle-outline')}
{renderSection('on_hold', 'On Hold', 'pause-circle-outline')}
{renderSection('dropped', 'Dropped', 'highlight-off')}
</ScrollView>
) : (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar
barStyle="light-content"
backgroundColor="transparent"
translucent
/>
<SafeAreaView style={styles.safeArea}>
<View style={styles.header}>
<TouchableOpacity onPress={() => navigation.goBack()} style={styles.backButton}>
<MaterialIcons name="arrow-back" size={24} color={currentTheme.colors.highEmphasis} />
</TouchableOpacity>
<Text style={[styles.headerTitle, { color: currentTheme.colors.highEmphasis }]}>
MyAnimeList
</Text>
{/* Requirement 6: Manual Sync Button */}
<TouchableOpacity onPress={handleRefresh} style={styles.syncButton} disabled={isLoading}>
{isLoading ? (
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
) : (
<MaterialIcons name="sync" size={24} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
</View>
)}
{selectedAnime && (
<MalEditModal
visible={isEditModalVisible}
anime={selectedAnime}
onClose={() => {
setIsEditModalVisible(false);
setSelectedAnime(null);
}}
onUpdateSuccess={fetchMalList}
/>
)}
</SafeAreaView>
{!isLoading || isRefreshing ? (
<ScrollView
refreshControl={
<RefreshControl refreshing={isRefreshing} onRefresh={handleRefresh} tintColor={currentTheme.colors.primary} />
}
contentContainerStyle={{ paddingBottom: 40 }}
>
{renderSection('watching', 'Watching', 'play-circle-outline')}
{renderSection('plan_to_watch', 'Plan to Watch', 'bookmark-outline')}
{renderSection('completed', 'Completed', 'check-circle-outline')}
{renderSection('on_hold', 'On Hold', 'pause-circle-outline')}
{renderSection('dropped', 'Dropped', 'highlight-off')}
</ScrollView>
) : (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
</View>
)}
{selectedAnime && (
<MalEditModal
visible={isEditModalVisible}
anime={selectedAnime}
onClose={() => {
setIsEditModalVisible(false);
setSelectedAnime(null);
}}
onUpdateSuccess={fetchMalList}
/>
)}
</SafeAreaView>
</View>
);
};
const styles = StyleSheet.create({
container: { flex: 1 },
safeArea: { flex: 1, paddingTop: Platform.OS === 'android' ? StatusBar.currentHeight : 0 },
header: {
flexDirection: 'row',
alignItems: 'center',

View file

@ -39,6 +39,7 @@ const MalSettingsScreen: React.FC = () => {
const [syncEnabled, setSyncEnabled] = useState(mmkvStorage.getBoolean('mal_enabled') ?? true);
const [autoUpdateEnabled, setAutoUpdateEnabled] = useState(mmkvStorage.getBoolean('mal_auto_update') ?? true);
const [autoAddEnabled, setAutoAddEnabled] = useState(mmkvStorage.getBoolean('mal_auto_add') ?? true);
const [autoLibrarySyncEnabled, setAutoLibrarySyncEnabled] = useState(mmkvStorage.getBoolean('mal_auto_sync_to_library') ?? false);
const [alertVisible, setAlertVisible] = useState(false);
const [alertTitle, setAlertTitle] = useState('');
@ -139,6 +140,11 @@ const MalSettingsScreen: React.FC = () => {
mmkvStorage.setBoolean('mal_auto_add', val);
};
const toggleAutoLibrarySync = (val: boolean) => {
setAutoLibrarySyncEnabled(val);
mmkvStorage.setBoolean('mal_auto_sync_to_library', val);
};
return (
<SafeAreaView style={[
styles.container,
@ -188,42 +194,117 @@ const MalSettingsScreen: React.FC = () => {
<Text style={[styles.profileName, { color: currentTheme.colors.highEmphasis }]}>
{userProfile.name}
</Text>
<Text style={[styles.profileUsername, { color: currentTheme.colors.mediumEmphasis }]}>
ID: {userProfile.id}
</Text>
<View style={styles.profileDetailRow}>
<MaterialIcons name="fingerprint" size={14} color={currentTheme.colors.mediumEmphasis} />
<Text style={[styles.profileDetailText, { color: currentTheme.colors.mediumEmphasis }]}>
ID: {userProfile.id}
</Text>
</View>
{userProfile.location && (
<View style={styles.profileDetailRow}>
<MaterialIcons name="location-on" size={14} color={currentTheme.colors.mediumEmphasis} />
<Text style={[styles.profileDetailText, { color: currentTheme.colors.mediumEmphasis }]}>
{userProfile.location}
</Text>
</View>
)}
{userProfile.birthday && (
<View style={styles.profileDetailRow}>
<MaterialIcons name="cake" size={14} color={currentTheme.colors.mediumEmphasis} />
<Text style={[styles.profileDetailText, { color: currentTheme.colors.mediumEmphasis }]}>
{userProfile.birthday}
</Text>
</View>
)}
</View>
</View>
<TouchableOpacity
style={[styles.button, styles.signOutButton, { backgroundColor: currentTheme.colors.error }]}
onPress={handleSignOut}
>
<Text style={styles.buttonText}>Sign Out</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, { backgroundColor: currentTheme.colors.primary, marginTop: 12 }]}
onPress={async () => {
setIsLoading(true);
try {
const synced = await MalSync.syncMalToLibrary();
if (synced) {
openAlert('Sync Complete', 'MAL data has been refreshed.');
} else {
openAlert('Sync Failed', 'Could not refresh MAL data.');
}
} catch {
openAlert('Sync Failed', 'Could not refresh MAL data.');
} finally {
setIsLoading(false);
}
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<MaterialIcons name="sync" size={20} color="white" style={{ marginRight: 8 }} />
<Text style={styles.buttonText}>Sync Now</Text>
{userProfile.anime_statistics && (
<View style={styles.statsContainer}>
<View style={styles.statsRow}>
<View style={styles.statBox}>
<Text style={[styles.statValue, { color: currentTheme.colors.primary }]}>
{userProfile.anime_statistics.num_items}
</Text>
<Text style={[styles.statLabel, { color: currentTheme.colors.mediumEmphasis }]}>Total</Text>
</View>
<View style={styles.statBox}>
<Text style={[styles.statValue, { color: currentTheme.colors.primary }]}>
{userProfile.anime_statistics.num_days_watched.toFixed(1)}
</Text>
<Text style={[styles.statLabel, { color: currentTheme.colors.mediumEmphasis }]}>Days</Text>
</View>
<View style={styles.statBox}>
<Text style={[styles.statValue, { color: currentTheme.colors.primary }]}>
{userProfile.anime_statistics.mean_score.toFixed(1)}
</Text>
<Text style={[styles.statLabel, { color: currentTheme.colors.mediumEmphasis }]}>Mean</Text>
</View>
</View>
<View style={[styles.statGrid, { borderColor: currentTheme.colors.border }]}>
<View style={styles.statGridItem}>
<View style={[styles.statusDot, { backgroundColor: '#2DB039' }]} />
<Text style={[styles.statGridLabel, { color: currentTheme.colors.highEmphasis }]}>Watching</Text>
<Text style={[styles.statGridValue, { color: currentTheme.colors.highEmphasis }]}>
{userProfile.anime_statistics.num_items_watching}
</Text>
</View>
<View style={styles.statGridItem}>
<View style={[styles.statusDot, { backgroundColor: '#26448F' }]} />
<Text style={[styles.statGridLabel, { color: currentTheme.colors.highEmphasis }]}>Completed</Text>
<Text style={[styles.statGridValue, { color: currentTheme.colors.highEmphasis }]}>
{userProfile.anime_statistics.num_items_completed}
</Text>
</View>
<View style={styles.statGridItem}>
<View style={[styles.statusDot, { backgroundColor: '#F9D457' }]} />
<Text style={[styles.statGridLabel, { color: currentTheme.colors.highEmphasis }]}>On Hold</Text>
<Text style={[styles.statGridValue, { color: currentTheme.colors.highEmphasis }]}>
{userProfile.anime_statistics.num_items_on_hold}
</Text>
</View>
<View style={styles.statGridItem}>
<View style={[styles.statusDot, { backgroundColor: '#A12F31' }]} />
<Text style={[styles.statGridLabel, { color: currentTheme.colors.highEmphasis }]}>Dropped</Text>
<Text style={[styles.statGridValue, { color: currentTheme.colors.highEmphasis }]}>
{userProfile.anime_statistics.num_items_dropped}
</Text>
</View>
</View>
</View>
</TouchableOpacity>
)}
<View style={styles.actionButtonsRow}>
<TouchableOpacity
style={[styles.smallButton, { backgroundColor: currentTheme.colors.primary, flex: 1, marginRight: 8 }]}
onPress={async () => {
setIsLoading(true);
try {
const synced = await MalSync.syncMalToLibrary();
if (synced) {
openAlert('Sync Complete', 'MAL data has been refreshed.');
} else {
openAlert('Sync Failed', 'Could not refresh MAL data.');
}
} catch {
openAlert('Sync Failed', 'Could not refresh MAL data.');
} finally {
setIsLoading(false);
}
}}
>
<MaterialIcons name="sync" size={18} color="white" style={{ marginRight: 6 }} />
<Text style={styles.buttonText}>Sync</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.smallButton, { backgroundColor: currentTheme.colors.error, width: 100 }]}
onPress={handleSignOut}
>
<Text style={styles.buttonText}>Sign Out</Text>
</TouchableOpacity>
</View>
</View>
) : (
<View style={styles.signInContainer}>
@ -311,6 +392,25 @@ const MalSettingsScreen: React.FC = () => {
/>
</View>
</View>
<View style={styles.settingItem}>
<View style={styles.settingContent}>
<View style={styles.settingTextContainer}>
<Text style={[styles.settingLabel, { color: currentTheme.colors.highEmphasis }]}>
Auto-Sync to Library
</Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Automatically add items from your MAL 'Watching' list to your Nuvio Library.
</Text>
</View>
<Switch
value={autoLibrarySyncEnabled}
onValueChange={toggleAutoLibrarySync}
trackColor={{ false: currentTheme.colors.border, true: currentTheme.colors.primary + '80' }}
thumbColor={autoLibrarySyncEnabled ? currentTheme.colors.white : currentTheme.colors.mediumEmphasis}
/>
</View>
</View>
</View>
</View>
)}
@ -371,7 +471,37 @@ const styles = StyleSheet.create({
avatarText: { fontSize: 24, color: 'white', fontWeight: 'bold' },
profileInfo: { marginLeft: 16, flex: 1 },
profileName: { fontSize: 18, fontWeight: '600' },
profileUsername: { fontSize: 14 },
profileDetailRow: { flexDirection: 'row', alignItems: 'center', marginTop: 2 },
profileDetailText: { fontSize: 12, marginLeft: 4 },
statsContainer: { marginTop: 20 },
statsRow: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 16 },
statBox: { alignItems: 'center', flex: 1 },
statValue: { fontSize: 18, fontWeight: 'bold' },
statLabel: { fontSize: 12, marginTop: 2 },
statGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
borderTopWidth: 1,
paddingTop: 16,
gap: 12
},
statGridItem: {
flexDirection: 'row',
alignItems: 'center',
width: '45%',
marginBottom: 8
},
statusDot: { width: 8, height: 8, borderRadius: 4, marginRight: 8 },
statGridLabel: { fontSize: 13, flex: 1 },
statGridValue: { fontSize: 13, fontWeight: '600' },
actionButtonsRow: { flexDirection: 'row', marginTop: 20 },
smallButton: {
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
},
signOutButton: { marginTop: 20 },
settingsSection: { padding: 20 },
sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 16 },

View file

@ -39,6 +39,7 @@ export const MalApiService = {
limit,
offset,
sort: 'list_updated_at',
nsfw: true // Ensure all content is returned
},
});
return response.data;
@ -64,11 +65,13 @@ export const MalApiService = {
malId: number,
status: MalListStatus,
episode: number,
score?: number
score?: number,
isRewatching?: boolean
) => {
const data: any = {
status,
num_watched_episodes: episode,
is_rewatching: isRewatching || false
};
if (score && score > 0) data.score = score;
@ -77,6 +80,10 @@ export const MalApiService = {
});
},
removeFromList: async (malId: number) => {
return api.delete(`/anime/${malId}/my_list_status`);
},
getAnimeDetails: async (malId: number) => {
try {
const response = await api.get(`/anime/${malId}`, {
@ -91,7 +98,11 @@ export const MalApiService = {
getUserInfo: async (): Promise<MalUser> => {
try {
const response = await api.get('/users/@me');
const response = await api.get('/users/@me', {
params: {
fields: 'id,name,picture,gender,birthday,location,joined_at,anime_statistics,time_zone'
}
});
return response.data;
} catch (error) {
console.error('Failed to get user info', error);

View file

@ -349,6 +349,12 @@ export const MalSync = {
mmkvStorage.setNumber(getLegacyTitleCacheKey(title, type), item.node.id);
}
console.log(`[MalSync] Synced ${allItems.length} items to mapping cache.`);
// If auto-sync to library is enabled, also add 'watching' items to Nuvio Library
if (mmkvStorage.getBoolean('mal_auto_sync_to_library') ?? false) {
await MalSync.syncMalWatchingToLibrary();
}
return true;
} catch (e) {
console.error('syncMalToLibrary failed', e);
@ -356,6 +362,49 @@ export const MalSync = {
}
},
/**
* Automatically adds MAL 'watching' items to the Nuvio Library
*/
syncMalWatchingToLibrary: async () => {
try {
logger.log('[MalSync] Auto-syncing MAL watching items to library...');
const response = await MalApiService.getUserList('watching', 0, 50);
if (!response.data || response.data.length === 0) return;
for (const item of response.data) {
const malId = item.node.id;
const { imdbId } = await MalSync.getIdsFromMalId(malId);
if (imdbId) {
const type = item.node.media_type === 'movie' ? 'movie' : 'series';
// Check if already in library to avoid redundant calls
const currentLibrary = await catalogService.getLibraryItems();
const exists = currentLibrary.some(l => l.id === imdbId);
if (!exists) {
logger.log(`[MalSync] Auto-adding to library: ${item.node.title} (${imdbId})`);
await catalogService.addToLibrary({
id: imdbId,
type: type,
name: item.node.title,
poster: item.node.main_picture?.large || item.node.main_picture?.medium || '',
posterShape: 'poster',
year: item.node.start_season?.year,
description: '',
genres: [],
inLibrary: true,
});
}
}
}
} catch (e) {
logger.error('[MalSync] syncMalWatchingToLibrary failed:', e);
}
},
/**
* Manually map an ID if auto-detection fails
*/

View file

@ -9,8 +9,28 @@ export interface MalUser {
id: number;
name: string;
picture?: string;
gender?: string;
birthday?: string;
location?: string;
joined_at?: string;
time_zone?: string;
anime_statistics?: {
num_items_watching: number;
num_items_completed: number;
num_items_on_hold: number;
num_items_dropped: number;
num_items_plan_to_watch: number;
num_items: number;
num_days_watched: number;
num_days_watching: number;
num_days_completed: number;
num_days_on_hold: number;
num_days_dropped: number;
num_days: number;
num_episodes: number;
num_times_rewatched: number;
mean_score: number;
};
}
export interface MalAnime {